Skip to content
Software Atlas

DOM Events

How an event travels through the page tree, how to stop it or cancel what the browser does next, why one listener can serve 10,000 rows, and how timing, React and production concerns change the picture.

⏱ ~40 min read14 sections12 interactive widgetsJavaScript · HTML · React
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Three bugs every front-end developer meets sooner or later: a click handler that runs twice, a dropdown that closes when you click inside it, and a table of 10,000 rows that gets slow because every row has its own listener. All three come from the same mechanism. Once you see it, they stop being mysteries.

In one sentenceWhen something happens on a page, the browser creates an event and walks it through the page tree: down from the window to the element that was hit, then back up again. Every listener on that path gets a turn.

First, the words. The page you see is a tree of objects called the DOM: <html> contains <body>, which contains your <ul>, which contains <li> rows, and so on (the trees topic covers trees in general). An event is a small object the browser makes when something happens: a click, a key press, a field getting focus. A listener is a function you ask the browser to call when a certain event reaches a certain element.

the whole API, in one line
button.addEventListener("click", (event) => {
  console.log("clicked!", event.target); // event.target = the element that was clicked
});
The journey of one click
You clicked the <button>. The event visits every ancestor twice: once on the way down, once on the way up.
1 ↓
window
↑ 13
2 ↓
document
↑ 12
3 ↓
<html>
↑ 11
4 ↓
<body>
↑ 10
5 ↓
<ul>
↑ 9
6 ↓
<li>
↑ 8
<button>← 7: target — the element you clicked
1–6 ↓ capture phase: window down to the parent7 target phase: the clicked element8–13 ↑ bubble phase: back up to window

Most listeners you'll ever write run on the way up (the bubble phase). That one fact explains a lot:

1

Parents hear children

A listener on a <ul> hears clicks on every <li> inside it, because each click bubbles up through the list. That's how one listener can serve 10,000 rows (§05).
2

You can stop the trip

event.stopPropagation() ends the journey early, and event.preventDefault() cancels what the browser would do next, like following a link (§04).
3

Timing matters

Each listener runs to the end before anything else happens on the page. Slow listeners freeze the page; visual updates belong in requestAnimationFrame (§08–§09).

The plan: §02–§04 teach the mechanism (listeners, phases, stopping and default actions), §05–§07 the everyday patterns (delegation, which events bubble, keyboard and pointer input), §08–§10 timing and React, §11 production concerns. Then pitfalls, a quiz and a cheat sheet.

Foundations§02

Events, listeners and their options

In one sentenceaddEventListener(type, fn, options) puts fn on an element's list for that event type. The browser calls it with an event object each time that event reaches the element.

The event object

Every Event listener receives one argument: the event. Here are the fields you'll use most:

FieldWhat it tells youExample
event.typeWhich event this is"click", "keydown", "input"
event.targetThe element it happened on — the deepest element under the pointerthe <span> inside your button
event.currentTargetThe element whose listener is running nowthe <button> you called addEventListener on
event.timeStampWhen it happened, in ms since the page loaded1834.2
event.isTrustedtrue if a real user caused it, false if your code made ittrue

Plus details for each kind: clientX/clientY (pointer position) on mouse events, key on keyboard events, and so on. The difference between event.target and event.currentTarget becomes important in §05. For now, remember: target = where it happened, currentTarget = where you're listening.

Adding and removing

listeners.js
const button = document.querySelector("#buy");

function onBuy(event) {
  console.log("buy!", event.type); // "buy! click"
}

button.addEventListener("click", onBuy);    // start listening
button.removeEventListener("click", onBuy); // stop: same type, SAME function

The browser keeps, for each element, a list of (event type, function, capture flag) entries. The capture flag says whether the listener runs on the event's way down the page tree or on its way back up; §03 explains both, and for now "up" is the default. Two rules about that list cause many real bugs:

  1. Adding the exact same function twice does nothing. The browser ignores the duplicate. But two arrow functions that look the same are two different functions, so both get added, and your handler runs twice. That is bug #1 from §01: a component that runs addEventListener("click", () => …) every time it sets itself up.
  2. Removing needs the exact same function and capture flag. removeEventListener("click", () => …) removes nothing, because that arrow function is brand new. And if you added with { capture: true }, you must remove with { capture: true } too.

We checked both rules in headless Chromium (Chrome's engine, driven by a script with no visible window; we use it to measure every claim in this topic): the same function added twice ran 1 time, two identical-looking arrows ran 2 times, and a remove with the wrong capture flag left the listener in place. Try it yourself:

Listener lab
Add listeners to a real button, then click it. The log shows which functions the browser called.
Registered on the button: 0
// no listeners yet: clicking Buy does nothing

The options object

The third argument is an options object. Each option answers one question:

options.js
const controller = new AbortController();

button.addEventListener("click", onBuy, {
  once: true,                 // remove me automatically after the first call
  capture: false,             // run on the way down (true) or the way up (false, the default) — §03
  passive: true,              // "I promise not to call preventDefault()" (which cancels the browser's own action, §04) — §08
  signal: controller.signal,  // remove me when controller.abort() is called
});

controller.abort(); // ← removes every listener that was given this signal

onclick versus addEventListener

You'll also see button.onclick = fn and <button onclick="…"> in older code. That is a single slot: assigning it again replaces the previous function. addEventListener keeps a list, so a second listener doesn't overwrite the first. Use addEventListener in plain JavaScript. (React's onClick prop is a different thing entirely; see §10.)

Foundations§03

Capture, target, bubble

In one sentenceEvery event goes down through the target's ancestors (capture), arrives at the target, then goes back up (bubble). Listeners run in that order.

Back to the sorting office from §01. The letter's route is decided before it starts moving: the browser takes the target element and lists every ancestor up to window. That list is the propagation path (you can see it yourself with event.composedPath()). Then the event walks that path in three phases. This journey is called Propagation.

Phaseevent.eventPhaseWhereWhich listeners run
1 · capture1window → … → the target's parentonly those added with { capture: true }
2 · target2the target element itselfall of its listeners: capture ones first, then the rest
3 · bubble3the target's parent → … → windowonly those added without capture (the default)

Here is a real log from headless Chromium. Three nested elements, outer > middle > inner, each with one capture listener and one bubble listener. We clicked inner:

measured: click on #inner (Chromium 153)
outer capture  (phase 1)
middle capture (phase 1)
inner capture  (phase 2)   ← at the target
inner bubble   (phase 2)   ← still at the target
middle bubble  (phase 3)
outer bubble   (phase 3)

With listeners on window, document and <body> too, the outer layers wrap around this: window capture, document capture, body capture, …, body bubble, document bubble, window bubble.

Now try it. The boxes below are real elements with real listeners. Switch listeners on and off, click the button, then click the <div>'s padding, and see which listeners run and in what order. (Ignore the stop controls for now: §04 explains them and uses them.)

Propagation lab
Real elements, real listeners. Click the button, the div or the section, and watch the order.
<section>
<div>
Listeners (click to switch on/off):
// click something on the left
The ● colour is how the listener was registered: capture or bubble. The log's first column is event.eventPhase when it ran: on the clicked element itself it is always target.

Three things to notice while you play:

  • Click the <div> padding instead of the button. Now the <div> is the target. Its capture listener (if on) runs in phase 2, not phase 1, and the button's listeners don't run at all: the button isn't on the path.
  • Registration order only matters on one element. On the button, "listener 1" always runs before "listener 2" because it was added first. Between different elements, the phase decides.
  • At the target, capture listeners run first. The target's capture listeners run before its non-capture ones, whatever order you added them in: we registered the bubble listener first and the capture one still ran first. This has been the rule in Chrome since version 89, after a change to the DOM standard; Firefox and Safari follow the same standard, but we only measured Chromium. Older browsers ran them in registration order, so old blog posts may tell you otherwise.
capture.js
// Runs before ANY listener inside the page sees the click:
document.addEventListener("click", logClick, { capture: true });

// The old way to write the same thing: a bare `true` as the third argument.
document.addEventListener("click", logClick, true);
Practitioner§04

Stopping events and default actions

In one sentencestopPropagation() stops the event's journey. preventDefault() cancels the browser's own reaction. They are independent: one does not imply the other.

Three methods, three different jobs

CallWhat it stopsWhat still happens
event.stopPropagation()the event moving to the next element on the pathother listeners on the current element still run; the default action still happens
event.stopImmediatePropagation()the next element and the remaining listeners on this elementthe default action still happens
event.preventDefault()the browser's default actionthe event keeps travelling; every listener still runs

We measured the first two on a button with two listeners and a parent <div> listener. The first button listener calls the method:

measured (Chromium 153)
stopPropagation():          btn handler 1, btn handler 2      ← div never hears it
stopImmediatePropagation(): btn handler 1                     ← nothing else runs

You can reproduce this in the propagation lab in §03: pick "button · listener 1" in the drop-down, then flip the toggle at the top between the two methods.

Default actions

Many events come with something the browser does by itself once your listeners have run. preventDefault() cancels it, but only if the event is cancelable (most user-input events are; scroll and focus are not).

EventDefault actionTypical reason to prevent it
click on <a href>follow the linka single-page app handles navigation itself
submit on <form>send the form and load a new pagesend it with fetch() instead
click on a checkboxtoggle checkedrarely a good idea (see the tip below)
keydown in a text fieldtype the characterinput masks, keyboard shortcuts
wheel / touchmovescrollcustom zoom, a map, a carousel
contextmenuopen the right-click menua custom menu

Try each one. The left switch decides whether its listener calls preventDefault():

Default actions
Each control has a real listener. The left switch decides whether it calls event.preventDefault().
Checkbox
Form
Keydown
Wheel
line 1 — scroll me
line 2 — scroll me
line 3 — scroll me
line 4 — scroll me
line 5 — scroll me
line 6 — scroll me
line 7 — scroll me
line 8 — scroll me
line 9 — scroll me
line 10 — scroll me
line 11 — scroll me
line 12 — scroll me
// try each control; flip preventDefault() on and off

Two details the lab shows that surprise people:

  • The checkbox flips first, then flips back. Inside the click listener, checked already has the new value. If you call preventDefault(), the browser puts it back after the listeners finish. Our measured log: inside the listener checked=true; after the event checked=false.
  • Pressing Enter in a form field clicks the submit button. We measured the order: click on the submit button first, then submit on the form, with event.submitter (the button that sent the form) pointing at that button. So handle submit on the form, not click on the button, or you'll miss Enter.

Why stopPropagation() is usually the wrong fix

Here is bug #2 from §01. A dropdown closes when you click outside it, using one listener on document. But a click inside the menu bubbles up to document too, so the menu closes when you click its own search box.

The tempting patch is stopPropagation() inside the menu. It works, but it hides those clicks from every listener further up: analytics, other "click outside" handlers, a parent component's logic. The real fix is to ask the right question in the document listener: "was this click inside the menu?" Compare the three versions:

The dropdown that closes when you click inside it
A document-level click listener closes the menu on outside clicks. Compare three versions.

Click any empty space in this box to test an outside click.

Open the menu, then click the search box inside it.
analytics script on document has seen 0 clicks in this demo
document.addEventListener("click", (e) => {
  if (!toggleButton.contains(e.target)) close(); // any other click closes
});
click-outside.js
// menu = the wrapper around BOTH the toggle button and the panel.
// If it were only the panel, the click that OPENS the menu would also
// reach this listener and close it again straight away.
const menu = document.querySelector("#filter-menu");

document.addEventListener("click", (event) => {
  if (!menu.contains(event.target)) closeMenu(); // ← only clicks outside the menu
});
Practitioner§05

Event delegation: one listener for many children

In one sentenceBecause clicks bubble, a listener on a parent hears every click inside it. Put one listener on the list, and use event.target.closest(…) to work out which row was clicked.

That's Event delegation. Here's the code for a to-do list where each row has a ★ and a ✕ button:

list.html
<ul id="todos">
  <li data-id="1"><span>Write the report</span>
    <button data-action="star">★</button>
    <button data-action="delete">✕</button>
  </li>
  <!-- …and 9,999 more rows -->
</ul>
delegation.js
const list = document.querySelector("#todos");

list.addEventListener("click", (event) => {             // ← the only listener
  const button = event.target.closest("button[data-action]");
  if (!button || !list.contains(button)) return;        // not on an action button: ignore
                                                        // (contains: closest() could climb past the list)

  const id = button.closest("li").dataset.id;           // which row?
  if (button.dataset.action === "delete") deleteTodo(id);
  if (button.dataset.action === "star") toggleStar(id);
});

Why closest() and not just event.target? Because target is the deepest element that was hit. If the button contained an icon <svg>, a click on the icon makes target the <svg> (or even a <path> inside it), not the button. closest() climbs from wherever the click landed to the element you care about. And currentTarget is always the <ul>, the element the listener is on. Click around and watch all three:

One listener, any number of rows
The <ul> has the only click listener. Click a row's text, its ★ or its ✕, then add rows: they work at once.
  • Write the report
  • Review a pull request
  • Book flights
3 rows · 1 listener
// click inside the list

Notice that rows you add later work straight away. With per-row listeners, every new row needs its own addEventListener, and every deleted row should have its listener removed. With delegation, the list's single listener covers rows that don't exist yet.

What it saves, measured

We built a 10,000-row list in headless Chromium and attached click handlers three ways:

10,000 rows: what the listeners cost
Median of 7 runs, measured on this machine (headless Chromium 153). Heap = JavaScript heap growth after a forced GC.
10,000 arrow functions (one per row)
8.3 ms
708 KB
10,000 listeners, one shared function
3.5 ms
2 KB
1 delegated listener on the <ul>
<0.1 ms
1 KB
Left bars: time to attach. Right bars: extra JS memory. The browser's own record of each listener lives outside the JS heap and isn't in these numbers.
  • One arrow function per row is the expensive one: each arrow is a new function object plus a Closure holding that row's variables, about 70 bytes of JS heap per row here, and it took ~8 ms to attach.
  • One shared function on every row costs almost no JS memory, but still makes 10,000 entries in the browser's internal listener lists, and still took ~3.5 ms.
  • One delegated listener costs nothing measurable either way.

When delegation doesn't fit

  • Events that don't bubble (focus, mouseenter, load on images…) never reach the parent. Use their bubbling twins where they exist (§06).
  • High-frequency events like pointermove on a whole page: a delegated listener runs for every move anywhere inside, then filters. That's fine when the filtering is cheap; profile it if it isn't.
  • Something below calls stopPropagation(): the event never reaches your listener. Another reason to avoid it (§04).
Practitioner§06

Not every event bubbles

In one sentenceMost events bubble, but a few only fire on the target. For the important ones there's a bubbling twin: focusin for focus, mouseover for mouseenter.

Every event object has a bubbles property. When it's false, the event still goes through the capture phase and the target, but skips the trip back up. So a normal (bubble) listener on a parent never runs. Here are the ones that catch people out:

Doesn't bubbleBubbling twinNotes
focus, blurfocusin, focusout"Does any field in this form have focus?" needs the twins
mouseenter, mouseleavemouseover, mouseoutThe twins also fire when you cross into a child, which you usually don't want
scroll on an elementnonescroll on the document does reach window; on an inner scroll box it stays there
load, error on <img>, <script>nonelisten on the element, or use capture on a parent

Move the pointer over the card and its child, and click between the two fields. Every listener here is on the outer element:

Does it bubble?
Every listener here is on the OUTER element only. Move the pointer across the card and its child; click between the two fields.
card (listeners here)
child — move in and out of me
mouseover0
mouseenter0
mouseout0
mouseleave0
form (listeners here)
focus0
focusin0
blur0
focusout0
mouseover/out bubble, so they also fire when you cross the child's edge. mouseenter/leave don't bubble: one each per real entry and exit. focus/blur don't bubble, so the form's listener never hears its fields; focusin/focusout do.

We measured one path on a bare test page (enter the card, move into the child, back out to the card, then leave): 3 mouseover (plus 3 mouseout) but only 1 mouseenter (plus 1 mouseleave). Here is the full log, listeners on the card only:

measured (Chromium 153)
mouseover:card  mouseenter:card                ← pointer enters the card
mouseout:card   mouseover:child                ← crosses into the child
mouseout:child  mouseover:card                 ← back out to the card
mouseout:card   mouseleave:card                ← leaves

The mouseover with target=child bubbled up from the child, and moving between card and child produced a mouseout and a mouseover each time, although the pointer never left the card. The widget's counters run higher if your path crosses the child more often. For the form: a listener for focus on the form never ran, while focusin ran once per field.

Shadow DOM: the event pretends to come from the host

Some elements carry a private sub-tree, the Shadow DOM. Web components (your own custom HTML tags, like <date-picker>, defined in JavaScript) use it, and so do some built-in controls (the play button inside <video> is one). Its insides are hidden from the rest of the page, and events respect that.

When a click happens inside a shadow tree and travels out into the page, the browser retargets it: outside the shadow tree, event.target becomes the host element, so page code never sees the private insides. Measured:

measured: click on a <button id=inside> inside a shadow root on <div id=host>
inside shadow root: target=#inside
document:           target=div#host; composedPath()[0]=#inside

Whether an event may leave a shadow tree at all is the composed flag. Built-in UI events like click, keydown and input are composed. Events you create yourself are not, unless you say so:

custom events and shadow DOM
// Inside a web component's shadow tree:
button.dispatchEvent(new CustomEvent("ping", { bubbles: true }));
// → heard inside the shadow root only

button.dispatchEvent(new CustomEvent("ping", { bubbles: true, composed: true }));
// → heard inside the shadow root AND on document (measured)
Practitioner§07

Pointer, keyboard and form events in practice

In one sentenceUse pointer events for anything you'd press or drag, event.key for "which character", event.code for "which physical key", input for every edit, change for the committed value, and submit on the form.

Open the inspector and try things while you read. Everything in it is a real listener logging real events:

Input inspector
Type in the field (try Shift, Enter, or an IME), and tap or click the pad with mouse, finger or pen.
pointer pad — click, tap, double-click
// events appear here, oldest at the top

Pointer events: one API for mouse, touch and pen

Phones send touch events, laptops send mouse events, tablets send pen events. Pointer events (pointerdown, pointermove, pointerup, pointercancel) cover all three, and event.pointerType tells you which it was. The browser still sends the older mouse and touch events too, for old code. Measured order for one click and one tap:

measured (Chromium 153), only the events we listened for
mouse click: pointerdown(mouse) mousedown  pointerup(mouse) mouseup  click
finger tap:  pointerdown(touch) touchstart pointerup(touch) touchend  mouseover mousedown mouseup click

So a tap produces a click too. That's why handling click alone works for buttons on every device, and why listening to both touchstart and mousedown for the same action runs your code twice on a phone. If you need press/drag/release, use pointer events and only pointer events.

drag.js
handle.addEventListener("pointerdown", (event) => {
  handle.setPointerCapture(event.pointerId); // ← keep getting moves even if the pointer leaves the handle
  startDrag(event.clientX, event.clientY);
});
handle.addEventListener("pointermove", (event) => {
  if (!handle.hasPointerCapture(event.pointerId)) return; // just hovering, not dragging
  moveDrag(event.clientX, event.clientY);
});
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag); // the browser took over (e.g. started scrolling)

Keyboard: key versus code

FieldMeaningShift + AOn a French AZERTY keyboard, same key
event.keythe character (or key name) it produces"A""Q"
event.codethe physical key position, named as on a US keyboard"KeyA""KeyA"

We measured the US-layout values in Chromium: Shift+KeyA gave key="A", code="KeyA", and Shift+Digit1 gave key="!" but code="Digit1". (The AZERTY column follows from where that layout puts its letters; we didn't measure it.) Rule: use key for shortcuts named by letter ("press ? for help", Escape, Enter) and code for position-based controls (WASD in a game). Avoid the old keyCode: it's deprecated and its numbers vary by browser and layout.

IME composition: when one character takes many keys

People typing Japanese, Chinese, Korean and other languages use an IME: they type several keys, pick a suggestion, then commit. While they're choosing, the field shows a draft, and the browser fires composition events. Measured, typing に → にほ → 日本:

measured with Chromium's IME emulation
compositionstart  data=""
compositionupdate data="に"    input data="に"
compositionupdate data="にほ"  input data="にほ"
compositionupdate data="日本"  input data="日本"
compositionend    data="日本"

The input events during composition carry draft text. If you run a search or validation on every input, you'll search for half-typed words. And if you submit on Enter in keydown, you'll submit while the user is just confirming their choice in the IME. Check event.isComposing first:

enter-to-send.js
input.addEventListener("keydown", (event) => {
  if (event.isComposing) return;   // ← the IME is using this Enter, not us
  if (event.key === "Enter") send();
});

Safari has a reported quirk here: the Enter that commits a composition can arrive after compositionend with isComposing already false (we couldn't test it; only Chromium is installed here). If you support Safari IME users, also ignore an Enter that comes right after a compositionend, or test with a real Japanese keyboard.

input vs change, and form submit

  • input fires on every edit: each keystroke, paste, cut, or IME update. Use it for live feedback (character counters, live search with a debounce, §09).
  • change fires when the value is committed: for a text field, when it loses focus or you press Enter (measured: change after Enter); for a checkbox, select or radio, right away.
  • submit fires on the <form>, whether the user clicked the button or pressed Enter in a field. Handle it there, call event.preventDefault() if you send it with fetch(), and read the values with new FormData(form).
form.js
form.addEventListener("submit", async (event) => {
  event.preventDefault();                     // ← stay on this page
  const data = new FormData(form);            // every named field
  const res = await fetch("/api/search", { method: "POST", body: data });
  if (!res.ok) showError(`Search failed: ${res.status}`); // fetch doesn't throw on 404/500
});
Practitioner§08

When handlers run: tasks, frames and passive listeners

In one sentenceYour listener runs on the main thread, start to finish, while the page waits. Keep it short, do visual work in requestAnimationFrame, and mark scroll and touch listeners passive.

Listeners are tasks on the main thread

Everything in this topic runs on one thread, the Main thread, which also lays out and paints the page. When you click, the browser puts a task in a queue; the Event loop picks it up and dispatches the event, calling your listeners one after another. How the loop picks tasks and when microtasks run is covered in Async / Await §07. Two consequences matter here:

  • A slow listener freezes the page. While it runs, nothing repaints and no other click is handled. Any main-thread job over 50 ms is called a Long task. The time from a click to the next painted frame is what Google's INP metric measures (more in Web Performance).
  • Microtasks run between listeners, but only for real input. Microtasks run whenever the call stack is empty. During a real click, the stack is empty between one listener and the next. During el.click() from your script, it isn't: your script is still on the stack.

Step through it:

Real click vs el.click()
Same two listeners, two ways to click. Step with ← →.
Code
1btn.addEventListener("click", () => {
2 Promise.resolve().then(() => log("micro 1"));
3 log("listener 1");
4});
5btn.addEventListener("click", () => {
6 Promise.resolve().then(() => log("micro 2"));
7 log("listener 2");
8});
9
10// Case A: a person clicks the button.
11// Case B: a script does it:
12btn.click();
13log("after click()");
Call stackrunning now
empty
Microtask queueruns when the stack is empty
empty
Log
(nothing yet)
Case A: a real click. The browser turns your click into a task (one job for the event loop). The call stack is empty: no JavaScript of ours is running.
1 / 10

We logged both orders in headless Chromium: real mouse click → listener 1, microtask 1, listener 2, microtask 2; btn.click() → listener 1, listener 2, after click(), microtask 1, microtask 2.

requestAnimationFrame: the right moment for visual changes

The screen is redrawn in frames, usually 60 per second (90 or 120 on many newer phones and laptops), so at 60 Hz each frame has about 16.7 ms: the Frame budget. (How a frame is built is in How Browsers Render.) requestAnimationFrame asks the browser to call you just before the next frame is drawn. We counted 61 callbacks in one second in headless Chromium, one per frame.

raf.js
let latestX = 0;
let scheduled = false;

window.addEventListener("pointermove", (event) => {
  latestX = event.clientX;          // just remember the newest value (cheap)
  if (scheduled) return;            // a frame is already booked
  scheduled = true;
  requestAnimationFrame(() => {     // ← once per frame, right before painting
    scheduled = false;
    dot.style.transform = `translateX(${latestX}px)`;
  });
});

Why not update the DOM directly in the listener? Some input events can arrive more often than frames are drawn, and every DOM write in between is wasted work nobody sees. Writing in rAF does at most one update per frame, always with the newest value. Chromium already lines up continuous events like pointermove and wheel with frames on most devices, so this pattern costs nothing and protects you on the ones where it doesn't. (event.getCoalescedEvents() gives you the in-between pointer positions if you're drawing a smooth line.)

Passive listeners and smooth scrolling

When you swipe or spin the wheel, the browser wants to scroll immediately, ideally on a separate thread, the compositor (How Browsers Render has the details). But if a touchstart, touchmove or wheel listener might call preventDefault() to cancel the scroll, the browser has to wait for your JavaScript first. If the main thread is busy, scrolling stutters.

A passive listener is your promise that you won't cancel:

passive.js
window.addEventListener("touchmove", trackSwipe, { passive: true }); // the browser scrolls without waiting

If you break the promise, the call is ignored and Chromium logs a console error. Both measured:

measured (Chromium 153)
preventDefault() in a { passive: true } listener → defaultPrevented: false
[error] Unable to preventDefault inside passive event listener invocation.

Browsers went one step further: wheel, touchstart and touchmove listeners on window, document or <body> are passive by default in Chromium and Firefox (Safari does this at least for the touch events; we only measured Chromium), because so many sites added them without needing to cancel. We added a plain document.addEventListener("wheel", e => e.preventDefault()) and spun the wheel: cancelable=false, the page scrolled 300 px anyway, and Chromium printed "Unable to preventDefault inside passive event listener due to target being treated as passive." If you really need to block scrolling (a map, a custom carousel), attach the listener to that element with { passive: false }.

Practitioner§09

Debounce and throttle

In one sentenceDebounce: wait until the events stop, then run once. Throttle: run at most once per time window while they keep coming. rAF: at most once per frame, for visual updates.

Some events arrive in floods: typing fires input per keystroke, dragging fires dozens of pointermove per second, resizing a window fires resize continuously. Often you don't need to react to every one.

Move your pointer over the pad below, stop for a moment, then move again. Each tick is one call. Try the three window sizes:

Debounce vs throttle, live
Move your pointer around the pad (or press Simulate). Each tick is one call of your handler. The plot scrolls: newest on the right.
move here
raw pointermove
0
rAF (≤ 1 per frame)
0
throttle
0
debounce
0
Counts = calls in the last 3 seconds. Window: 300 ms.

What you should see: raw and rAF lanes look almost the same. That's because Chromium already delivers pointermove at most about once per frame (§08). The Simulate button is different: it calls the handler every 12 ms, faster than the screen refreshes, so there the rAF lane is visibly sparser than raw (we saw about 100 raw ticks vs 63 rAF ticks). That gap is rAF doing its job: extra calls inside one frame are merged. Throttle ticks at a steady rhythm while you move. Debounce stays silent while you move and ticks once, a moment after you stop.

The code

function debounce(fn, ms) {
  let timer;                                  // shared by every call below
  return (...args) => {
    clearTimeout(timer);                      // ← cancel the previous plan
    timer = setTimeout(() => fn(...args), ms);
  };
}

const search = debounce((q) => fetchResults(q), 300);
input.addEventListener("input", (e) => search(e.target.value));

We ran both in Node 22 with 100 calls, one every 10 ms (a one-second burst), and a 300 ms window:

measured: which calls ran, and when (ms after start)
debounce: call 100 @ 1308 ms                   ← 1 run, 300 ms after the burst ended
throttle: 1 @ 11, 30 @ 312, 60 @ 612, 90 @ 913, 100 @ 1214   ← every 300 ms, plus the last one

Throttle's last run (call 100) is the trailing call: without it, the final position of a drag or scroll would be lost.

Which one when?

SituationUseWhy
Search as you type, autosave, validate a fielddebounce (200–500 ms)You want the final value, once
Infinite scroll check, analytics on scroll, window resize logicthrottle (100–250 ms)Steady updates during the action
Moving, resizing, drawing anything on screenrAFOnce per frame is exactly as fast as the eye can see
Practitioner§10

React's event system

In one sentenceReact doesn't put your onClick on the button. It puts one listener per event type on the root container (the element you passed to createRoot, usually <div id="root">) and runs your handlers itself, walking your component tree like the browser walks the DOM.

You've written <button onClick={save}> many times. Here's what actually happens in React 17 and later (we checked React 19.3):

  1. When you call createRoot(container), React adds its own listeners to container: both a capture and a bubble listener for almost every event type. We counted about 140 listeners on the root in React 19.3 (the exact number varies a little between builds).
  2. Your button gets no listener of its own for onClick.
  3. When a real click bubbles up to the root, React looks at event.target, finds the components on the way up, and calls their onClick props in order. That's Event delegation (§05), done for you.

Your handler gets a Synthetic event: a wrapper with the familiar fields (target, currentTarget, preventDefault(), stopPropagation()) and the real browser event in e.nativeEvent. Click the button below, then try the two checkboxes:

React handlers vs native listeners
This is real React 19 (the site itself is a React app). Click the button and compare the order.
<div onClickCapture onClick>
// click the button

Here's the measured order, with nothing stopped:

measured: React 19.3, Chromium 153
1. native · document (capture)
2. React · <div onClickCapture>        ← React's capture listener on the root
3. native · <button> addEventListener   ← the button really has this listener
4. React · <button onClick>             ← React's bubble listener on the root
5. React · <div onClick>
6. native · document (bubble)

Each line follows from the phases in §03 once you know React's listeners sit on the root:

  • onClickCapture runs during the capture phase, when the event passes the root on its way down (step 2), so before the button's own listener.
  • A native listener on the button runs before the button's React onClick (step 3 before 4): the event reaches the button before it bubbles up to the root.
  • React's e.stopPropagation() stops both trees. React stops its own walk and calls the native stopPropagation(), so document listeners (step 6) don't run. Measured: lines 5 and 6 disappeared.
  • A native stopPropagation() on the button hides the click from React completely. The event never reaches the root, so no React onClick above that point runs (measured: the log stops at line 3). This is how a third-party widget can "eat" clicks in your React app.

Native listeners in React components

Sometimes you need a real listener: on window (resize, keyboard shortcuts), on document (click outside), or with options React doesn't expose (passive: false). Add it in an effect and remove it in the cleanup:

useKeyboardShortcut.jsx
import { useEffect } from "react";

function useKeyboardShortcut(key, onPress) {
  useEffect(() => {
    const controller = new AbortController();
    window.addEventListener("keydown", (event) => {
      if (event.key === key && !event.isComposing) onPress(event);
    }, { signal: controller.signal });
    return () => controller.abort();   // ← React calls this when the component goes away
  }, [key, onPress]); // wrap onPress in useCallback, or this re-subscribes on every render
}

In development, React's StrictMode sets every effect up, cleans it up, and sets it up again. If your cleanup forgets to remove the listener, you'll see the handler run twice: bug #1 from §01 again. Why React re-renders and what StrictMode does are covered in How React Works.

Staff-level§11

Production: leaks, custom events, postMessage, accessibility

In one sentenceAt scale, event bugs are lifetime bugs (listeners that outlive their element), trust bugs (messages from the wrong origin), and reach bugs (handlers only a mouse can trigger).

1. Listener leaks keep whole components alive

A listener on a long-lived object (window, document, a global store) holds on to its function, and the function's Closure holds on to whatever it uses, often the component's own DOM element. Remove the element from the page and forget the listener, and the garbage collector can't free any of it: the element is still reachable from window. That's a Memory leak, the classic one in single-page apps, where the page itself is never reloaded to wipe the slate clean. The GC topic explains reachability in depth.

We measured it: 20 "page visits", each creating 50 cards that add a resize listener on window and then removing the cards from the page.

✕ Don't
// Leaky: nothing ever removes the listener
function mountCard(card) {
  window.addEventListener("resize", () => {
    card.style.width = innerWidth / 3 + "px"; // ← keeps `card` alive
  });
}

for (const card of cards) mountCard(card);
// "leave the page":
app.replaceChildren(); // cards gone from the page,
                       // still reachable from window
✓ Do
// Fixed: one controller per page visit
function mountCard(card, signal) {
  window.addEventListener("resize", () => {
    card.style.width = innerWidth / 3 + "px";
  }, { signal });                          // ← removable
}

const controller = new AbortController();
for (const card of cards) mountCard(card, controller.signal);
// "leave the page":
app.replaceChildren();
controller.abort(); // ← removes all 50 listeners
After 1,000 cards were removed (forced GC)DOM nodes still aliveListeners on window
Leaky (no cleanup)7,0051,000
With { signal } + controller.abort() on unmount50

Measured on this machine (headless Chromium 153) with Memory.getDOMCounters, a DOM-node counter that Chrome exposes to test scripts through its DevTools protocol. The 5 nodes left in the fixed version are the test page's own. Every removed card, with its heading, paragraph and button, stayed in memory. In DevTools, a heap snapshot filtered for "Detached" shows exactly these: elements no longer in the page but still reachable.

2. Custom events: components that talk without importing each other

Any element can fire events you invent, using CustomEvent. A product card can announce "added to cart" and the header badge can listen on document, without either importing the other. That's the observer pattern (Behavioral patterns) built into the DOM.

custom-events.js
// The card announces:
const ok = card.dispatchEvent(new CustomEvent("cart:add", {
  detail: { sku: 42, qty: 1 }, // ← your data goes in `detail`
  bubbles: true,               // default is false: only the card itself would hear it
  cancelable: true,            // let listeners veto it with preventDefault()
}));
if (!ok) showMessage("Can't add that right now"); // a listener called preventDefault()

// The header listens, anywhere above:
document.addEventListener("cart:add", (event) => {
  badge.textContent = Number(badge.textContent) + event.detail.qty;
});

Things we measured that you need to know:

  • bubbles defaults to false for events you make (the parent heard nothing until we passed bubbles: true). Built-in clicks bubble; your events don't unless you ask.
  • dispatchEvent is synchronous. All listeners run before dispatchEvent returns, inside your code. Measured order: before dispatch → child handler → after dispatch. It's a function call through the DOM, not a message queue.
  • It returns false if a listener called preventDefault() on a cancelable event. That's your veto mechanism.
  • An exception in one listener doesn't stop the others. We threw an error in listener 1: listener 2 still ran, click() returned normally, and the error went to window's error event. Good for robustness, but errors in listeners never reach the code that dispatched the event, so make sure they reach your error reporting.

3. Cross-frame messages: always check the origin

A page and an iframe from a different origin can't touch each other's DOM (CORS & CSRF explains why). They talk with postMessage instead, and the receiver gets a message event. Any window that can get a reference to yours (an iframe you embed, a popup, the page that opened you) can send you messages. So the listener must check who sent each one.

We embedded two iframes, a real widget and an "evil" one. Both sent a "resize" message; this is what the parent's listener received:

measured
message from https://widget.test: {"type":"resize","height":320}   source is widget frame: true
message from https://evil.test:   {"type":"resize","height":99999} source is widget frame: false
messages.js
// Receiving: check the sender before trusting anything
window.addEventListener("message", (event) => {
  if (event.origin !== "https://widget.example.com") return;   // ← who sent it
  if (event.source !== widgetFrame.contentWindow) return;      // ← and from which frame
  if (event.data?.type === "resize") widgetFrame.style.height = `${Number(event.data.height)}px`;
});

// Sending: name the receiver's origin, never "*" for anything private
widgetFrame.contentWindow.postMessage({ type: "theme", value: "dark" }, "https://widget.example.com");

Why the sender names an origin

The second argument of postMessage protects your data. A frame's address can change after you embedded it: a compromised ad, an open redirect, or the frame's own bug can send it to another site, and your widgetFrame.contentWindow reference now points at that site. If you send a secret with "*", whoever is in the frame gets it.

We tested exactly that: the parent sends a token to a frame that started on widget.test, then the frame navigates itself to evil.test, and the parent sends again.

sending a secret
const frame = document.querySelector("#widget").contentWindow;

frame.postMessage("token=abc123", "https://widget.test"); // ← delivered only if the frame IS widget.test
frame.postMessage("token=abc123", "*");                   // ← delivered to whoever is in the frame now
measured (Chromium 153)
frame on widget.test, target "https://widget.test" → widget got: token=abc123
frame on evil.test,   target "https://widget.test" → not delivered; console:
  Failed to execute 'postMessage' on 'DOMWindow': The target origin provided
  ('https://widget.test') does not match the recipient window's origin ('https://evil.test').
frame on evil.test,   target "*"                   → EVIL got: token=abc123

So: check event.origin when you receive, and name the exact origin when you send. Message data that ends up in innerHTML is a classic DOM-based XSS hole, so treat event.data like any other user input.

4. Accessibility: every click needs a keyboard path

Many people never use a mouse: keyboard users, Screen reader users, switch-device users. A <div> with a click listener can't be reached with Tab and doesn't react to Enter or Space. A <button> does all of that for free, and it fires click for Enter and Space too, so one click listener covers everyone.

Both snippets below use inline onclick only to stay short; in real code attach the listener with addEventListener as in §02.

✕ Don't
<!-- ✗ mouse-only: no focus, no Enter/Space, a screen reader says "group" or nothing -->
<div class="btn" onclick="save()">Save</div>
✓ Do
<!-- ✓ focusable, Enter and Space fire click, announced as "Save, button" -->
<button type="button" onclick="save()">Save</button>

Keep that parity everywhere: hover menus also open on focus, drag-and-drop has a keyboard alternative, and custom keyboard shortcuts skip key presses while the user is typing in a field. The Accessibility topic covers focus management and ARIA properly.

Practitioner§12

Pitfalls gallery

Eight bugs that show up in real code reviews, each with the fix. Every "measured" note comes from headless Chromium 153.

#1

The click handler that runs twice: a label around an input

Clicking a <label>'s text makes the browser send a second click to its input (that's how labels toggle checkboxes). That second click bubbles back up through the label. Measured: one physical click, two calls: target=label, then target=input.

✕ Don't
label.addEventListener("click", () => {
  toggleSetting(); // runs twice: undone again!
});
✓ Do
// listen for the state change instead:
checkbox.addEventListener("change", () => {
  toggleSetting(checkbox.checked); // once per real change
});
#2

The click handler that runs twice: setup code that runs again

Each () => … is a new function, so the browser can't spot the duplicate (§02). Any setup that can run twice (a re-render, React StrictMode, a route you visit again) stacks another listener.

✕ Don't
function setup() {
  button.addEventListener("click", () => save());
}
setup(); setup(); // two listeners → save() twice
✓ Do
let controller;
function setup() {
  controller?.abort();                // remove the old ones first
  controller = new AbortController();
  button.addEventListener("click", () => save(), { signal: controller.signal });
}
#3

removeEventListener that removes nothing

removeEventListener matches on type + the same function + the same capture flag (§02). .bind(this) also creates a new function each time you call it.

✕ Don't
el.addEventListener("scroll", this.onScroll.bind(this), true);
// later:
el.removeEventListener("scroll", this.onScroll.bind(this)); // new function, wrong flag
✓ Do
this.onScroll = this.onScroll.bind(this); // bind ONCE, keep it
el.addEventListener("scroll", this.onScroll, true);
el.removeEventListener("scroll", this.onScroll, true);
#4

preventDefault() after an await

Listeners run synchronously. Once your listener hits its first await, it returns to the browser, dispatch finishes, and the default action (following a link, submitting a form) happens. A preventDefault() that runs later is too late. Measured: with await before preventDefault(), Chromium went ahead and loaded the form's result page.

✕ Don't
form.addEventListener("submit", async (e) => {
  const ok = await validate();   // the listener returns here…
  if (!ok) e.preventDefault();   // …so this is too late: the form already submitted
});
✓ Do
form.addEventListener("submit", async (e) => {
  e.preventDefault();            // decide synchronously, first
  if (await validate()) await send(new FormData(form));
});
#5

Reading event.currentTarget later

currentTarget is only set while a listener runs. Afterwards the browser sets it back to null. Measured: currentTarget read inside a setTimeout was null.

✕ Don't
list.addEventListener("click", (e) => {
  setTimeout(() => e.currentTarget.classList.add("seen"), 100);
  // TypeError: Cannot read properties of null (reading 'classList')
});
✓ Do
list.addEventListener("click", (e) => {
  const el = e.currentTarget;            // copy it now
  setTimeout(() => el.classList.add("seen"), 100);
});
#6

this in an arrow-function listener

With a regular function listener, this is the element (same as currentTarget). Arrow functions don't have their own this, so inside one it's whatever this was outside. Code copied from an old function example to an arrow silently changes meaning. Use event.currentTarget in both cases; it can't change under you.

button.addEventListener("click", function () { this.disabled = true; }); // this = button
button.addEventListener("click", () => { this.disabled = true; });       // this = outer this (often undefined)
button.addEventListener("click", (e) => { e.currentTarget.disabled = true; }); // ✓ clear and safe
#7

mouseover for hover effects

mouseover bubbles, so it fires again each time the pointer crosses into a child (§06: 3 mouseover vs 1 mouseenter on one path through a card with one child). A tooltip that re-opens or flickers is usually this. Use mouseenter/mouseleave, or better, pointer events: pointerenter/pointerleave. For pure styling, CSS :hover needs no JavaScript at all.

#8

Heavy work in a scroll or input listener

Each listener runs on the main thread before the next frame can paint (§08). Sorting 5,000 rows on every keystroke, or measuring layout on every scroll, makes typing and scrolling stutter. Debounce the expensive part, write visual changes in rAF, and mark scroll/touch listeners passive (§08–§09).

input.addEventListener("input", debounce((e) => renderResults(filter(e.target.value)), 200));
window.addEventListener("scroll", onScroll, { passive: true });
Practitioner§13

Check yourself: predict what happens

Commit to an answer before you click. Every answer was checked in headless Chromium, and each explanation points to the section to re-read.

QUESTION 1 / 7
Predict the log order

Click the <button> inside #card.

card.addEventListener("click", () => log("card bubble"));
btn.addEventListener("click",  () => log("btn"));
card.addEventListener("click", () => log("card capture"), { capture: true });
QUESTION 2 / 7
stopPropagation() and a second listener

A button has two click listeners. The first calls e.stopPropagation(). Does the second one run?

QUESTION 3 / 7
The click that fires twice

<label id="lbl"><input type="checkbox"> Remember me</label> and lbl.addEventListener("click", h). You click the words “Remember me”. How many times does h run?

QUESTION 4 / 7
A scripted click and microtasks

Each listener runs Promise.resolve().then(() => log("m" + n)) and then log("L" + n). A script calls btn.click(); log("after").

QUESTION 5 / 7
Which listener sees the field get focus?

You want one listener on a <form> that runs whenever any of its inputs gets focus.

QUESTION 6 / 7
React vs native: who runs first?

A button has a React onClick and a native addEventListener("click"). A user clicks it.

QUESTION 7 / 7
preventDefault() that does nothing

document.addEventListener("wheel", (e) => e.preventDefault()) with no options. You spin the mouse wheel.

Answered 0 / 7
Foundations§14

Cheat sheet

🔽
Capture ↓, target, bubble ↑Every event walks down to the target and back up. Listeners default to the way up; { capture: true } runs on the way down.
🎯
target vs currentTargettarget = where it happened (deepest element). currentTarget = where this listener is attached. closest() climbs from target to what you care about.
✋
stop vs preventstopPropagation ends the journey (other listeners on this element still run). preventDefault cancels the browser's own action. Prefer contains() checks over stopping.
📮
DelegateOne listener on the parent handles any number of children, including ones added later. React does this at its root.
🧹
Clean up with a signalOne AbortController per component; pass signal to every listener and fetch; abort() on unmount. Forgotten listeners leak whole components.
⏱️
Short listeners, visual work in rAFListeners block the main thread. Debounce for “after they stop”, throttle for “steadily while it happens”, rAF for anything on screen.
🧈
Passive for scroll and touch{ passive: true } lets the browser scroll without waiting. On window/document/body, wheel and touch listeners are passive by default.
⌨️
Keyboard parityUse <button>: Enter and Space fire click. Check isComposing before acting on Enter.
You want…Use
Run once, then stop listening{ once: true }
Remove many listeners at once{ signal } + controller.abort()
Handle clicks on many rowsone listener on the parent + event.target.closest("li")
Know if focus is anywhere inside a formfocusin / focusout (they bubble)
Hover without child flickerpointerenter / pointerleave (or CSS :hover)
Press / drag on mouse, touch and penpointerdown / pointermove / pointerup + setPointerCapture
Every edit vs the committed valueinput vs change (React's onChange = input)
Handle a form, including Entersubmit on the <form> + preventDefault() + FormData
Shortcut by letter vs by key positionevent.key vs event.code
Click outside to closedocument listener + !menu.contains(event.target)
Your own announcementnew CustomEvent(type, { detail, bubbles: true })
Talk to an iframepostMessage(data, exactOrigin); check event.origin on receipt
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Frontend & BrowserAccessibility
Read →