First principles · interrupt → React

What is an event, really — from the silicon up to React

"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.

Run the event loop ▸ Play with dispatch
00 — The Journey, Live

Press Play: One Keypress, Frame by Frame

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.

Live — the whole path in one line
▶ keydown · interrupt → kernel → loop → dispatch → react → paint
00 / 12
FRAME 00 · —

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.

Hardware — interrupt Kernel — readiness Event loop — task & microtasks DOM — capture · target · bubble React — synthetic · batch
00 — The Map, Whole

The Entire Path in One Picture

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.

▶ architecture · silicon → kernel → loop → dom → react → render
01 — The Stack of Abstractions

One Notification, Re-packaged Four Times

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.

L0
Hardware · Kernel
An event is a hardware interrupt
keypress → interrupt controller → CPU → kernel ISR

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.

L1
Host · Browser
The native loop & the JS event loop
run-to-completion · tasks vs microtasks · no preemption

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.

L2
DOM · Web Platform
Three-phase dispatch over the DOM tree
capture → target → bubble · composedPath snapshot

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.

L3
React · Synthetic
Delegated, normalized, batched re-simulation
one root listener · SyntheticEvent · fiber-tree walk

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.

02 — The Event Loop, Live

Tasks, Microtasks & the Render Step

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.

Call stack — synchronous, run-to-completion
Microtask queue — promises, drained fully each time
Task queue — timers, events, one per tick
Console / render
app.js

            
Call Stack0
Microtask Queue0
Task Queue (macrotasks)0
Console0
00 / 12
IDLE · waiting
Press play to trace the loop
A program is itself a task. The loop hasn't started executing your JS yet — the whole script sits in the task queue as one macrotask, waiting to be picked up.
03 — Three-Phase Dispatch

Capture · Target · Bubble — Fire One Yourself

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.

DOM tree — concentric = ancestor chain
window
document
div#outer
div#inner
button#target

Listeners

Interruptions

● Firing order — handler · phase · currentTarget
Dispatch a click to see handlers fire in order…
The trap 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.
04 — Where L1 Meets L2

Why el.click() Reorders Your Microtasks

A 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.

two listeners, each queueing a microtask
btn.addEventListener('click', () => {
  console.log('listener 1');
  queueMicrotask(() => console.log('  ↳ microtask 1'));
});
btn.addEventListener('click', () => {
  console.log('listener 2');
  queueMicrotask(() => console.log('  ↳ microtask 2'));
});
Real user click
browser invokes each listener · stack empties between
btn.click() from JS
dispatch nested in your stack · empties at the end
The takeaway The microtask checkpoint is tied to stack-emptiness, not to "the event being done." Real clicks interleave m1 right after listener 1; .click() defers both microtasks until after listener 2. A clean demonstration that L2 dispatch rides entirely on the L1 loop.
05 — React's Synthetic System

One Root Listener, A Whole Fiber Walk

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.

DOM  ·  native bubble → React synthetic
#root container React listener
<div.app> onClickCapture
<div.toolbar> onClick
<button> onClick · target

Configuration

Two propagations, one click
The native event must reach the delegated root listener before React's synthetic phase even begins. Run a click to watch it.
Why delegate at all Not just "fewer listeners." Handlers change every render (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.
06 — Events Trigger Renders

Automatic Batching: Three Updates, One Render

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.

0
flag: false
renders this interaction: 0

React version

Update context

The handler calls setCount twice and setFlag once — three state updates. Watch how many renders that costs.
07 — Anatomy of the Primitives

Every Moving Part, One Card at a Time

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.

events · internals · from interrupt to react
Back to JIT-Atlas