The event loop is the mastermind that orchestrates:
-
what JavaScript code gets executed
-
when does it run
-
when do layout and style get updated
-
render: when do DOM changes get rendered
It is formally specified in whatwg's HTML standard.
The current specification is incomplete. The event loop behavior differs between browsers. Further work is being done to clarify the issues:
This document is a developer-oriented description of what event loop does. It tries to be readable, and might skip over edge cases for clarity.
This is what the spec says:
eventLoop = {
taskQueues: {
events: [], // UI events from native GUI framework
parser: [], // HTML parser
callbacks: [], // setTimeout, requestIdleTask
resources: [], // image loading
domManipulation[]
},
microtaskQueue: [
],
nextTask: function() {
// Spec says:
// "Select the oldest task on one of the event loop's task queues"
// Which gives browser implementers lots of freedom
// Queues can have different priorities, etc.
for (let q of taskQueues)
if (q.length > 0)
return q.shift();
return null;
},
executeMicrotasks: function() {
if (scriptExecuting)
return;
let microtasks = this.microtaskQueue;
this.microtaskQueue = [];
for (let t of microtasks)
t.execute();
},
needsRendering: function() {
return vSyncTime() && (needsDomRerender() || hasEventLoopEventsToDispatch());
},
render: function() {
dispatchPendingUIEvents();
resizeSteps();
scrollSteps();
mediaQuerySteps();
cssAnimationSteps();
fullscreenRenderingSteps();
animationFrameCallbackSteps();
while (resizeObserverSteps()) {
updateStyle();
updateLayout();
}
intersectionObserverObserves();
paint();
}
}
while(true) {
task = eventLoop.nextTask();
if (task) {
task.execute();
}
eventLoop.executeMicrotasks();
if (eventLoop.needsRendering())
eventLoop.render();
}
Now we understand the spec.
Event loop does not say much about when events are dispatched:
-
Events on the same queue are dispatched in order.
-
Events can be dispatched directly, bypassing the event loop task queues.
-
Microtasks get executed immediately after a task.
-
Render part of the loop gets executed on vSync, and delivers events in the following order:
-
dispatch pending UI events
-
'resize' event
-
'scroll' event
-
mediaquery listeners
-
'CSSAnimation' events
-
Observers
-
rAF
-
There are two classes of UI events:
-
Discrete - those that aren't continuous (eg. mousedown, mouseup, touchstart, touchend)
-
Continuous - mousewheel, wheel, mousemove, pointermove, touchmove.
Continuous events may be coalesced (updating positions, magnitude) with matching events (that haven't yet been dispatched) while being held in the UI event task queue.
Ordering of discrete and continuous events must be preserved. Discrete events should be dispatched right as soon as possible when received from the hardware. Continuous events can be held and dispatched in the render part of the event loop. If a discrete event is received, all continuous events in the task queue should run immediately to prevent the discrete event from being delayed.
We've built a test page. The page simultaneously generates (listed in order):
-
2 requestAnimationFrames
-
2 setTimeout(0)
-
2 chained promises
-
CSSAnimation
-
scroll event
-
resize event
We compare when these callbacks get executed to what is expected by the spec. Here are the results from major browsers. Spoiler: they are all different. Pretty graphical analysis also available here
301.66 script start
302.91 script end
303.31 promise 0
303.86 promise 1
305.43 timeout 0
305.83 timeout 1
316.21 scroll
316.62 matchMedia
316.92 resize
317.29 animationstart
317.62 rAF 0
318 rAF 0 promise
318.31 rAF 1
17ms
Chrome behavior almost matches the spec. Failure:
- scroll event fires before resize event. It looks like Chrome puts scroll and resize on the same task queue.
Chrome's conformance was expected, as spec was written with large input by the Chrome team :)
322 script start
323.47 animationstart
324.58 script end
326.2 scroll
327.96 matchMedia
330.03 resize
338.39 promise 0
339.13 promise 1
339.94 timeout 0
341.11 timeout 1
356.52 rAF 0
357.22 rAF 1
362.27 rAF 0 promise
40ms
Firefox diverges from the spec.
-
scroll event fires before resize event.
-
Promises are not on a microtask queue, but a different task queue. Instead of executing immediately after
scriptEnd
, they execute afterresize
, and afterrAF 1
instead ofrAF 0
. -
Timeout fires in the middle of the render part of the loop. According to spec, it should fire either before, or after, but not in the middle.
-
CSSAnimation event is delivered immediately, and not inside the
render
block.
328.17 script start
329.32 script end
329.53 promise 0
329.96 scroll
330.24 timeout 0
330.38 timeout 1
330.5 promise 1
330.81 matchMedia
332.57 animationstart
332.88 resize
344.67 rAF 0
344.95 rAF 0 promise
345.09 rAF 1
17ms
Safari diverges from the spec:
-
scroll event fires before resize event.
-
Chained Promise does not execute immediately, it happens after timeout.
-
timeout fires in the middle of
render
block, betweenscroll
andmatchMedia
.
510.09 script start
512.08 script end
512.4 promise 0
512.69 promise 1
512.89 timeout 0
513.06 timeout 1
513.3 animationstart
526.24 scroll
528.1 rAF 0
528.44 rAF 0 promise
528.67 rAF 1
528.93 matchMedia
529.17 resize
19ms
Microsoft diverges from the spec. It does well with Promises and timeout,
but mostly ignores render
event ordering from the spec, sprinking
different events all over.
There are significant differences between browsers' implementation of the event loop. The spec is not helpful in determining what actually happens.
It will be hard for developers to try to understand when is their arbitrary code going to get executed. If order of execution is important, be careful from the start: architect your ordered callbacks using only primitives whose behavior is well understood.
Here are a few rules of thumb:
-
callbacks of the same type always execute in order requested. Callbacks of different types execute in unspecified order. Therefore, you should not mix callback types in your callback chain.
-
rAF always fire before rendering, no more than 60 times/sec. If your callbacks are manipulating DOM, use rAF, so that you do not disturb layout unless painting.
-
timeout(0) fires "in the next task loop". Use sparingly, only if you need high-frequency callbacks that are not tied to drawing.
-
Promises fire "sooner than timeout".
DOM has 250 event types. See https://cs.chromium.org/chromium/src/out/Debug/gen/blink/core/EventTypeNames.h
To find out how any particular event is handled, follow the code search links in code searh.
Events are dispatched by following methods:
Triggered by a timer
Sample events: window.storage, window.hashchange, document.selectionchange
Triggered by BeginMainFrame function that's called by Frame.
Also manages requestAnimationFrame requests
sample events: Animation.finish, Animation.cancel, CSSAnimation.animationstart, CSSAnimation.animationiteration(CSSAnimation)
Triggers vary: OS events, timers, document/element lifecycle events.
Custom dispatch event do not pass through queues, they are fired directly.
There are no globally applicable delivery guarantees for custom events. Specific events might have event-specific guarantees about ordering.
Triggered most often by EndOfTaskRunner.didProcessTask().
Tasks are run by TaskQueueManager. They are used internally by other dispatchers to schedule execution.
Microtask queue executes whenever task completes.
sample events: Image.onerror, Image.onload
Microtasks also contain Promise callbacks
Implementation of the UI event task queue. Events are coalesced in this queue. This queue also handles requesting the vSync when continuous events have been placed in this task queue.
Triggered by internal timer when browser is idle.
Triggered by ScriptedAnimationController, that also handles events.
Timers: setTimeout, setInterval
Triggered by WebTaskRunner, which runs on TaskQueue primitive.
Observers watch for changes, and report all observations at once.
There are two ways of watching for changes:
-
Push: trap changes when they happen, and push to observation queue.
-
Poll: poll for changes when it is time to broadcast.
Push-based.
Observations broadcast is placed on microtask queue.
Poll-based.
Observations poll on layout, broadcast via 100ms timeout.
Completed promises run callbacks after completion.
Callbacks are placed on the microtask queue.