Advanced JavaScript Events: Bubbling, Capturing, Event Delegation and CustomEvent
We walk through the capturing, target and bubbling phases, stopPropagation, event delegation with a single listener, the once/passive/signal options, CustomEvent and pointer events, with runnable examples.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
There's a button inside a div. We've attached three listeners in three different places. When you click the button, which lines show up in the console, and in what order?
<div id="outer"><button id="inner">Click</button></div>
<script>
const outer = document.querySelector("#outer");
const inner = document.querySelector("#inner");
outer.addEventListener("click", () => console.log("outer div"));
inner.addEventListener("click", () => console.log("button"));
document.addEventListener("click", () => console.log("document"), { capture: true });
</script>
Make a guess before you read on. Most people say "button first, then the outer div" and either put document last or leave it out entirely. We'll come back to the answer once we've seen how an event travels through the page.
An Event Is a Journey: Three Phases
You already know how to listen for a click on an element with addEventListener, which we saw earlier. But a click doesn't happen on that element alone. When the browser fires an event, it carries it along a path through the DOM tree, and that path has three phases.
Picture a courier. To deliver a package, they first pass the gate of the housing complex, then the building entrance, then the hallway on your floor. That's the capturing phase: the event starts at window and travels down toward the target. The moment they knock on your door is the target phase. After the delivery, the courier walks back out the same way; that's the bubbling phase, where the event climbs from the target back up to window. Every "checkpoint" along the way gets a chance to notice the event.
By default, addEventListener runs in the bubbling phase. Pass { capture: true } as the third argument and the listener runs on the way down instead. To see the phase, look at event.eventPhase: 1 is capturing, 2 is target, 3 is bubbling. Save this file, open it in your browser, click the button and watch the console:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Event flow</title>
</head>
<body>
<section id="card">
<button id="buy">Buy</button>
</section>
<script>
const log = (name) => (event) => console.log(name, "phase:", event.eventPhase);
document.body.addEventListener("click", log("body (capture)"), { capture: true });
document.querySelector("#card").addEventListener("click", log("section"));
document.querySelector("#buy").addEventListener("click", log("button"));
document.body.addEventListener("click", log("body (bubble)"));
</script>
</body>
</html>
The output order is body (capture) phase: 1, button phase: 2, section phase: 3, body (bubble) phase: 3. That also answers the opening question: document comes first, because it was registered with capture and gets visited first on the way down. Then the button, then the outer div. If you put document at the top, you'd already sensed how event flow works.
target vs currentTarget
The event object stays the same throughout the journey, but two of its properties tell you different things. event.target is the element where the event actually started, the thing that was clicked. event.currentTarget is the element whose listener is running right now. In the courier picture, target is the apartment the package is headed to, and currentTarget is the checkpoint inspecting it at this moment.
document.querySelector("#card").addEventListener("click", (event) => {
console.log("target:", event.target.id); // "buy"
console.log("currentTarget:", event.currentTarget.id); // "card"
});
Remember the this rule we saw earlier: in a listener written as a regular function, this is the same as currentTarget. Arrow functions don't bring their own this, so event.currentTarget is the safer and clearer choice there.
Stopping Propagation
Sometimes you don't want an event to keep climbing. Say you have a modal that closes when you click the backdrop, but clicking a button inside the modal shouldn't close it. That's what event.stopPropagation() is for: from that point on, the event doesn't move to any other element.
dialogBox.addEventListener("click", (event) => {
event.stopPropagation(); // the click never reaches the backdrop
});
backdrop.addEventListener("click", () => closeModal());
stopImmediatePropagation() goes one step further: it also skips the other listeners attached to the same element. Don't confuse either of these with preventDefault(), which we saw earlier. preventDefault cancels the browser's default behavior (following a link, submitting a form) but leaves the event's journey alone. stopPropagation cuts the journey short but leaves the default behavior alone.
A warning: don't make stopPropagation a habit. Analytics scripts, "close the menu when clicking outside" logic and the delegation code you're about to see often listen on document. Cut the journey short and they quietly stop working, and tracking down why can eat an afternoon. Checking event.target and deciding based on that is usually the cleaner fix.
Event Delegation: One Listener, Hundreds of Elements
This is where bubbling really pays off. Instead of attaching a listener to every row in a hundred-row list, you attach a single listener to the list itself and find out which row was clicked from event.target. That's called event delegation. Think of a building manager: rather than posting a person at every apartment, one person stands at the entrance and checks which apartment each package belongs to.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Event delegation</title>
</head>
<body>
<button id="add">Add task</button>
<ul id="tasks">
<li data-id="1">Buy milk <button data-action="delete"><span>Delete</span></button></li>
<li data-id="2">Pay the bill <button data-action="delete"><span>Delete</span></button></li>
</ul>
<script>
const list = document.querySelector("#tasks");
let nextId = 3;
list.addEventListener("click", (event) => {
const button = event.target.closest('[data-action="delete"]');
if (!button || !list.contains(button)) return;
const item = button.closest("li");
console.log("Deleting:", item.dataset.id);
item.remove();
});
document.querySelector("#add").addEventListener("click", () => {
list.insertAdjacentHTML(
"beforeend",
`<li data-id="${nextId}">New task ${nextId} <button data-action="delete">Delete</button></li>`
);
nextId++;
});
</script>
</body>
</html>
Three things matter here. First, rows added later work without any extra code, because the listener lives on the list, not on the rows. Second, there's a span inside the button; if the click lands on the span, event.target is the span. That's exactly why closest, which we saw earlier, exists: it walks up from the target and finds the element you're after. Third, dataset lets you read which row you're working on straight from the HTML.
Not every event bubbles. focus and blur don't climb; for delegation, use their bubbling siblings focusin and focusout. mouseenter and mouseleave don't bubble either; mouseover and mouseout are the ones that do.
Listener Options: once, passive, signal
The third argument to addEventListener can be an options object. We've seen capture; the other three come in handy all the time.
// Runs only once, then removes itself
button.addEventListener("click", () => console.log("First click"), { once: true });
// Promises the browser "I won't call preventDefault"
window.addEventListener("wheel", onWheel, { passive: true });
// Removing several listeners at once with an AbortController
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
window.addEventListener("keydown", onKey, { signal: controller.signal });
controller.abort(); // both are gone
once: true is ideal for one-off jobs like a welcome message or starting audio on the first interaction. passive: true is about scrolling performance. On an event like wheel or touchmove, the browser needs to know whether your listener will call preventDefault before it scrolls the page; if it can't know, it waits, and scrolling stutters. A passive listener removes that wait. Call preventDefault inside one and nothing happens, though the browser may log a warning. One detail: wheel, touchstart and touchmove listeners on window, document and body are already passive by default in browsers. If you really need to block scrolling there, you have to write { passive: false } explicitly.
The signal option shows that the AbortController we met in the Fetch post is useful here too. It's especially handy when you remove a widget from the page and want to clean up all of its listeners in a single line.
Make Your Own Event: CustomEvent
When the browser's events aren't enough, you can define your own. Create one with CustomEvent, put whatever data you need in detail, and fire it from an element with dispatchEvent. That way one part of the page can announce "a product was added to the cart" and another part can listen for it, without either of them needing to know about the other.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CustomEvent</title>
</head>
<body>
<p>Cart: <span id="count">0</span> items</p>
<div id="products">
<button data-name="Pen">Add pen</button>
<button data-name="Notebook">Add notebook</button>
</div>
<script>
let count = 0;
document.addEventListener("cart:add", (event) => {
count++;
document.querySelector("#count").textContent = count;
console.log("Added:", event.detail.name);
});
document.querySelector("#products").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
button.dispatchEvent(new CustomEvent("cart:add", {
bubbles: true,
detail: { name: button.dataset.name }
}));
console.log("dispatchEvent finished");
});
</script>
</body>
</html>
Two details to notice. Events you create yourself don't bubble by default; without bubbles: true, the event fired from the button never reaches document. Second, dispatchEvent is synchronous. In the console you'll see "Added" first, then "dispatchEvent finished"; every listener runs to completion before the dispatchEvent line returns. A prefix like cart:add in the event name keeps you from colliding with the browser's own event names.
Keyboard, Mouse and Pointer Events
On the keyboard side you use keydown and keyup; the old keypress is deprecated. event.key gives you what the key means ("a", "Enter", "ArrowUp"), while event.code gives you its physical position on the keyboard ("KeyA"). On different layouts, such as QWERTY versus AZERTY, the same physical key produces a different letter, which is worth knowing when you define shortcuts. ctrlKey, metaKey, shiftKey and altKey report modifier keys, and repeat tells you the key is being held down.
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
console.log("Open the search box");
}
});
For the mouse you have click, dblclick, contextmenu, mousedown, mouseup and mousemove. clientX/clientY give the position relative to the viewport, and button tells you which button was pressed. These days, though, it's better to reach for pointer events, which handle touch screens, pens and mice with a single model: pointerdown, pointermove, pointerup. event.pointerType tells you "mouse", "touch" or "pen". You can drag the box below with a mouse or a finger:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Drag with pointer events</title>
<style>
#box { position: absolute; width: 80px; height: 80px; background: tomato; touch-action: none; }
</style>
</head>
<body>
<div id="box"></div>
<script>
const box = document.querySelector("#box");
let offsetX = 0;
let offsetY = 0;
box.addEventListener("pointerdown", (event) => {
offsetX = event.clientX - box.offsetLeft;
offsetY = event.clientY - box.offsetTop;
box.setPointerCapture(event.pointerId);
console.log("Input type:", event.pointerType);
});
box.addEventListener("pointermove", (event) => {
if (!box.hasPointerCapture(event.pointerId)) return;
box.style.left = `${event.clientX - offsetX}px`;
box.style.top = `${event.clientY - offsetY}px`;
});
</script>
</body>
</html>
setPointerCapture makes sure the following pointer events keep coming to the box even if the cursor slips outside it quickly; the capture ends on its own when you lift your finger. The CSS touch-action: none stops the browser from trying to scroll the page on a touch screen.
Common Pitfalls
You can't remove a listener added with an anonymous function. removeEventListener needs the same function reference; whatever you added with addEventListener("click", () => {...}) can't be found later. Keep the function in a variable, or use signal.
The capture flag has to match. A listener added with { capture: true } isn't removed by removeEventListener("click", fn); you have to remove it with the same flag. once and passive don't count for matching; only capture does.
Trusting event.target directly in delegation. If there's an icon or a span inside the button, that becomes the target; always climb to the element you want with closest.
Forgetting bubbles on your own event. The event fires, nobody hears it, and you get no error either. It's the sneakiest kind of silent bug.
A Small Experiment
★☆☆ Add a "done" button to the list in the delegation example. Clicking it should strike through the row, while the number of listeners stays at one.
★★☆ Predict the output, then try it: in the first example file, add event.stopPropagation() inside the section listener. When you click the button, which lines remain in the console and which disappear? Explain why the body (capture) line still shows up.
★★★ When the Escape key is pressed, fire a CustomEvent named ui:close on document. Have two different panels on the page listen for it and hide themselves. Once one of the panels has closed, remove its listener with an AbortController.
Forms are where events live most densely: every keystroke, every selection, every submit is an event. In the next post we go back to forms, this time with FormData, the browser's built-in validation API, and sending a form with fetch without reloading the page.