Update dispatchEvent documentation for clarity#43521
Update dispatchEvent documentation for clarity#43521
Conversation
Clarified the difference between native events and manually dispatched events in the dispatchEvent documentation. The previous phrasing implied that the actual event handler functions themselves are scheduled asynchronously on the Event Loop, but only the dispatch is.
|
Preview URLs (1 page) (comment last updated: 2026-03-27 14:07:25) |
This rephrase clarifies that what is async for native events is the dispatch process itself, _not_ the actual event handlers. Better phrasing after @dipikabh remark.
| 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. |
There was a problem hiding this comment.
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:
| 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!
There was a problem hiding this comment.
This looks right to me :) I would suggest:
| 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. |
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
Wait, now I'm not sure what the issue is claiming.
For a "native" event:
- The browser receives a mouse click; it enqueues all registered event handlers on the task queue.
- 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:
- The function gets called. All registered event handlers get called synchronously.
- 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
do they go up on the event loop? If not then at least "via the event loop" should be omitted :)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
Description
Clarified the difference between native events and manually dispatched events in the dispatchEvent documentation.
Motivation
The previous phrasing implied that the actual event handler functions themselves are scheduled asynchronously on the Event Loop, but only the dispatch is.
Additional details
Related issues and pull requests
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
Fixes #43519