Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions files/en-us/web/api/eventtarget/dispatchevent/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ should have already been created and initialized using an {{domxref("Event/Event
> [!NOTE]
> When calling this method, the {{domxref("Event.target")}} property is initialized to the current `EventTarget`.

Unlike "native" events, which are fired by the browser and invoke event handlers
asynchronously via the [event loop](/en-US/docs/Web/JavaScript/Reference/Execution_model),
`dispatchEvent()` invokes event handlers _synchronously_. All applicable event
handlers are called and return before `dispatchEvent()` returns.
Unlike calling `dispatchEvent()` manually, "native" events are fired by the browser and dispatched asynchronously via the [event loop](/en-US/docs/Web/JavaScript/Reference/Execution_model). The dispatch process itself is similar in both cases and invokes event handlers _synchronously_. All applicable event handlers are called and return before `dispatchEvent()` returns.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rewrite ends up comparing two different things - "calling" and "events".
I suggest we either keep the original phrasing ("Unlike "native" events...")
OR
break it down into two sentences and also change the voice to active to differentiate between browser and developer actions. ("when you call" is a subtle hint that you call this method manually)

What do you think about:

Suggested change
Unlike calling `dispatchEvent()` manually, "native" events are fired by the browser and dispatched asynchronously via the [event loop](/en-US/docs/Web/JavaScript/Reference/Execution_model). The dispatch process itself is similar in both cases and invokes event handlers _synchronously_. All applicable event handlers are called and return before `dispatchEvent()` returns.
The browser queues "native" events on the [event loop](/en-US/docs/Web/JavaScript/Reference/Execution_model) and fires them asynchronously. In contrast, when you call `dispatchEvent()`, it invokes event handlers synchronously. Note that `dispatchEvent()` returns only after all applicable event handlers have executed.

@Josh-Cena could you cross-check the technical accuracy of my suggestion here. Thanks!

Copy link
Copy Markdown
Member

@Josh-Cena Josh-Cena Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks right to me :) I would suggest:

Suggested change
Unlike calling `dispatchEvent()` manually, "native" events are fired by the browser and dispatched asynchronously via the [event loop](/en-US/docs/Web/JavaScript/Reference/Execution_model). The dispatch process itself is similar in both cases and invokes event handlers _synchronously_. All applicable event handlers are called and return before `dispatchEvent()` returns.
The browser queues "native" events on the [event loop](/en-US/docs/Web/JavaScript/Reference/Execution_model#job_queue_and_event_loop), so each event handler is executed asynchronously in a separate job. In contrast, when you call `dispatchEvent()`, it invokes event handlers synchronously and returns only after all applicable event handlers have executed.

Copy link
Copy Markdown
Author

@yuval-a yuval-a Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Josh-Cena - but that phrasing brings back the technical confusion/inaccuracy that this PR aims to correct from the first place.

Correct me if I'm wrong.

The difference to "native" - is that the call for the dispatch itself is async, but during it, event listeners run synchronously - don't go up the event loop.

The statement

"each event handler is executed asynchronously[...]"

makes it sound as if each of them is scheduled asynchronously on the event loop.

Interestingly, I tried to physically verify this by running some code in the console:

const btn = document.createElement('button'); 
btn.textContent = 'click me'; 
document.body.appendChild(btn); 
btn.addEventListener('click', () => { 
   console.log('listener 1 start'); 
   Promise.resolve().then(() => { 
      console.log('microtask from listener 1'); 
   }); 
   setTimeout(() => { console.log('timeout from listener 1'); }, 0); 
   console.log('listener 1 end'); 
}); 
btn.addEventListener('click', () => { console.log('listener 2'); }); 

The microtask log does run between listeners, the timeout log only after both run.
When running the same principle with a manual dispatchEvent and a custom event - the microtask log only shows after both listeners run. In native - there is a microtask checkpoint between listeners. But, still - the listeners themselves doesn't run async, no separate async queuing for each...
Or are they...? I mean, this test shows at least that no async timers run between (tried with other types of asyncs as well - same result) - my only doubt is that maybe they DO - and have a different "async priority" / are queued as a batch.
There is no place in the specs that explicitly says they run async in native, anyway.

What is the definite proof...?

EDIT: I'm learning to read the specs / distinguish between the terms, and it definitely says the handlers are "called" and not queued.

Copy link
Copy Markdown
Member

@Josh-Cena Josh-Cena Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, now I'm not sure what the issue is claiming.

For a "native" event:

  1. The browser receives a mouse click; it enqueues all registered event handlers on the task queue.
  2. At some indefinite time in the future, the event handler is successfully dequeued from the task queue and gets executed. It's asynchronous in the sense that it doesn't reuse the job of any other JavaScript execution—it's the entrypoint of a whole JavaScript job. It's synchronous in the sense that the browser doesn't "await" it, whatever that should mean when there's no relevant return value anyway.

Imagine this:

btn.addEventListener("click", function onclick1() { foo(); });
btn.addEventListener("click", function onclick2() { bar(); });

The job queue:

job 1: whatever code is currently running
job 2: click event handler 1
  stack frame 1: onclick1()
  stack frame 2: foo()
job 3: click event handler 2
  stack frame 1: onclick2()
  stack frame 2: bar()

When job 2 executes, onclick1 may schedule more jobs via setTimeout, new Promise, etc. Because new Promise makes a microtask with higher priority, when job 2 finishes, the next task the browser picks out will be the microtask instead of job 3, which has to wait a bit more. This is the speculation behavior you have observed.

For dispatchEvent:

  1. The function gets called. All registered event handlers get called synchronously.
  2. Each event handler executes in the current JavaScript job. It's definitely synchronous by every definition of synchronicity.

The job queue:

job 1: whatever code is currently running
  stack frame 1: dispatchEvent()
  stack frame 2: onclick1()
  stack frame 3: foo()
  ...
  stack frame 2: onclick2()
  stack frame 3: bar()

Nothing is allowed to execute between onclick1() and onclick2() because it's one synchronous block.

My acceptance of the original issue was that the word "asynchronous" is ambiguous in the context of native events because an event handler cannot observe its asynchronicity anyway, when by definition it has no relevant caller or callee. The key point here is that the event handler executes in a separate JavaScript job.

Now I look back at your suggestion, I don't think it makes a lot of sense either:

Unlike "native" events, which are fired by the browser and invoke dispatchEvent asynchronously via the [event loop]

You are implying the following:

job 1: whatever code is currently running
job 2: all click event handlers
  stack frame 1: dispatchEvent()
  stack frame 2: onclick1()
  stack frame 3: foo()
  ...
  stack frame 2: onclick2()
  stack frame 3: bar()

This would mean no other job is allowed to execute between onclick1() and onclick2(). This is false.

So I think there's something to be rewritten about this paragraph, but not your suggestion verbatim. Sorry I didn't read your suggestion in detail when I triaged the issue.

Copy link
Copy Markdown
Author

@yuval-a yuval-a Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand, the mental model (for native events) is more like:
job 1: start internal event dispatch process, which are these steps:
https://dom.spec.whatwg.org/#dispatching-events
these include, for each of the 3 phases: capturing, target, bubbling:
cloning the event handlers registered on each EventTarget object along the "event path" - and calling them one-by-one (like in a for-loop. Specs say: "For each"):
https://dom.spec.whatwg.org/#concept-event-listener-inner-invoke

the key point/question here, again, is if they are called "asynchronously" (as in what JS developers usually expect when they see this term) or not;
They are called in what the specs define as "callback invocation"
https://webidl.spec.whatwg.org/#js-invoking-callback-functions

which, as I'm researching thus far, is not the same as what variations of "running asynchronously" usually mean. It is a specific mechanism, that includes cleanup and microtasks checkpoint after each, but not a "macrotask" checkpoint.
i.e. Promises/Microtasks queued from a listener will run when it's done, but any other "async macrotask" will only run after ALL listeners finished running.

I've seen all sorts of discussions surrounding this, and I also saw this:
whatwg/dom#1308
(A proposal for "asynchronous event listeners" - which I think strengthens my point that event listeners are not considered "asynchronous" in the common/usual way).

so, I think what's actually happening is something that's between the first model you describe in your comment and the second one (that you describe as what I was implying).

And in that model: queued microtasks will run in-between, queued macrotasks will not run in-between, only after.

The issue may be mostly "technical semantics";
I still think the current phrasing:

Unlike "native" events, which are fired by the browser and invoke event handlers asynchronously via the event loop

does not reflect what is technically happening; Event handlers do not go up the event loop the same way other "usual" async tasks usually do.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the specs mention something that is supposed to be queued on the event loop - they specifically use the term "Queue a task" - linking to the part detailing the steps for it:
https://html.spec.whatwg.org/multipage/webappapis.html#queue-a-task

For event handlers they say they are called by callback invocation, linking to this:
https://webidl.spec.whatwg.org/#invoke-a-callback-function
and not by queuing a task.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because in https://webidl.spec.whatwg.org/#call-a-user-objects-operation, when the callback finishes, the browser performs cleanup which includes clearing the microtask queue. This is analogous in every way (that I can think of) in userland to having a separate macrotask for every callback; it's just specified this way

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do they go up on the event loop? If not then at least "via the event loop" should be omitted :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I read the spec, I actually don't think there's a big difference between dispatchEvent and the "fire an event" operation! So yes I think we should remove event loop and also try to rewrite it more significantly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're going through a similar rabbit hole I went through. I thought at first there is almost no difference.

But, did the experiment from a few comments back, comparing both:
two listeners, the first one schedules both a microtask and a macrotask (timeout), with logs.

In manual (calling dispatchEvent): two listeners finished, then microtask, them macrotask.
In native: one listener finished, microtask, listener 2 finished, macrotask.

So there was a significant difference :) dispatch seems to be 100% synchronous. Native... there's a microtask checkpoint between - so again I think the actual event handlers invocation is different in native - not synchronous, but not fully "async" as well.
In manual they all run sequentially 100% - nothing in between goes through until dispatch returns (it's a "chunk"), in native microtasks get through.


## Syntax

Expand Down
Loading