"Event" is one word smeared across five layers of abstraction. A hardware interrupt becomes a kernel notification, becomes a task the browser enqueues, becomes a run-to-completion dispatch, which React intercepts and re-simulates. Step through every layer.
The whole stack as one explorable. A single token travels five swim-lanes — hardware, the kernel, the event loop, DOM dispatch, and React. Scrub it, step it, or click any node to crack open exactly what happens at that moment.
Press play to trace the keypress
Each frame highlights as the event travels its lane — from an electrical interrupt at the bottom, up through the kernel and the JS event loop, across the DOM's three-phase walk, into React's synthetic system, and out as a single render.
The same journey laid flat: every box a primitive, every flowing edge a hand-off. Hover to isolate a node; click any box to open its deep dive. Left to right is bottom-of-stack to top.
Every layer below is the same pattern — notify whoever registered interest — with more policy bolted on. addEventListener is the JS-level expression of the exact thing the kernel does when you register a file descriptor with epoll. Click a layer to open it; press the button to watch a single event travel up.
Before any JavaScript exists, an event is electrical. You press a key: the keyboard controller raises a line on the interrupt controller, the CPU stops mid-instruction, and jumps to the kernel's interrupt service routine. The kernel decodes the scancode into something user-space can consume. Same story for the mouse, the NIC signalling a packet, a timer firing.
The kernel doesn't poke every process with every interrupt — that's what the I/O-readiness syscalls are for. The browser blocks in epoll_wait / kqueue / Windows IOCP, or the platform's windowing queue (GetMessage, the Cocoa run loop, Wayland). First principle: an event is just a notification that something happened, and the whole stack re-packages that notification for whoever registered interest.
The browser is a native C++ program running its own while(1) blocked on the OS — exactly the event-driven loop you'd write in C. That host loop is not the JS event loop; the JS loop is a much smaller thing running inside it.
JavaScript runs with run-to-completion semantics: once a function starts, nothing interrupts it. No preemption, no timer yanking the CPU mid-statement, no second thread observing a half-mutated object. That's why you never need a mutex around a plain object — and why one while(true) freezes the tab. Two queue tiers sit on top: coarse tasks (one per iteration) and fine microtasks (the whole queue drained after every task). Section 02 runs it live.
A click is delivered to the native loop, the browser hit-tests the layout tree to find the target node, and enqueues a task to dispatch. When the JS loop runs that task, it first snapshots the ancestor chain (composedPath()) — static, so mutating the DOM mid-dispatch can't change the path — then walks it three times: capture (top-down), target, bubble (bottom-up).
The Event object is mutated as it travels: target is fixed, but currentTarget changes per node. Section 03 lets you fire one and watch it. preventDefault() cancels the native default action; stopPropagation() halts the walk; stopImmediatePropagation() also skips remaining listeners on the current node.
React deliberately doesn't use the DOM model directly. onClick is not addEventListener on your button — it's a prop stored in the fiber. React attaches one delegated listener per event type at the root container (the DOM node you mount into, since React 17 — it used to be document). The native event bubbles to the root, React catches it, then runs its own propagation over the fiber tree, wrapping the native event in a SyntheticEvent.
Consequences explored in Sections 05–06: e.stopPropagation() only stops React's synthetic walk; a native handler can starve React's root listener; events carry priorities feeding the concurrent scheduler; and updates inside a handler are batched into one render.
The single most important model to internalize. Press play and watch one script run to completion, drain its microtasks, hit a render opportunity, then service a timer. Notice both promise callbacks beat the 0 ms timer — that ordering is the whole game.
Click the button. The event travels down the capture path, fires at the target, then travels back up bubbling. Toggle capture listeners, pick a node to call stopPropagation(), and watch handlers go dark. preventDefault() cancels the checkbox toggle without stopping propagation — they're independent.
stopPropagation() halts the walk to other nodes but still runs the rest of the listeners on the current node — that's what stopImmediatePropagation() is for. And preventDefault() never stops propagation; it only cancels the browser's default action (navigation, submit, the checkbox toggle above). Conflating the two is the most common dispatch bug.
el.click() Reorders Your MicrotasksA microtask checkpoint runs whenever the call stack empties. With a real browser click, the browser invokes each listener, so the stack returns to empty between them — microtasks flush between every listener. But el.click() nests the whole dispatch inside your stack, which never empties until your function returns — so those microtasks all wait until the end. Same listeners, different order, purely from who holds the stack.
btn.addEventListener('click', () => { console.log('listener 1'); queueMicrotask(() => console.log(' ↳ microtask 1')); }); btn.addEventListener('click', () => { console.log('listener 2'); queueMicrotask(() => console.log(' ↳ microtask 2')); });
.click() defers both microtasks until after listener 2. A clean demonstration that L2 dispatch rides entirely on the L1 loop.
React attaches one real listener at the root container, not on your elements. The native event bubbles up the DOM to that root; React catches it and re-simulates propagation over the fiber tree as a normalized SyntheticEvent. Run a click — then add a native stopPropagation() mid-tree and watch React's onClick mysteriously never fire.
onClick={() => doThing(state)} is a fresh closure each time) — with delegation React just swaps the prop in the fiber, never touching the DOM with add/removeEventListener churn. The 17 move from document to the root was so multiple React versions and micro-frontends can coexist on one page. And onFocus/onBlur actually bind native focusin/focusout, because delegation needs bubbling.
Because React owns the dispatch boundary, it knows exactly when an interaction starts and ends — so it can defer flushing until the handler returns, collapsing many setState calls into a single re-render. Pre-18 this only worked inside React's synthetic handlers; React 18 extended it to timers, promises, and native handlers too. Pick a version and a context, then fire the handler.
Each card opens a deep dive: what the primitive is, why it matters, and the two views that meet at it — the layer below and the layer above. A few cards mount a small animation you can step through. This is the same machinery, decomposed into the pieces that recur at every layer.
The through-line, bottom to top: a hardware interrupt becomes a kernel notification, becomes a task the browser enqueues, becomes a run-to-completion dispatch walking the capture/target/bubble path, which React intercepts at a single delegated root, re-simulates over its fiber tree as a normalized synthetic event, and uses as the priority-tagged, batched trigger for a render. Each layer is the same "notify the registered party" pattern — just with more policy bolted on.