DOM and Events
Event loop, event delegation, bubbling and capture, target vs currentTarget, and how to work with the DOM and events in JavaScript.
The Document Object Model (DOM) is the live representation of your HTML in memory. Understanding how to query it, mutate it, and respond to user actions via events is essential for any frontend developer. This guide covers the core concepts you need and how to use them in real UIs.
The DOM Tree
The browser parses HTML into a tree of nodes. Element nodes represent tags; text nodes represent text content. You query the DOM with document.querySelector, getElementById, or querySelectorAll. The returned nodes are live—changes to the DOM are reflected in your references. Remember that querying is relatively expensive; cache references when you use them repeatedly.
When to use which: getElementById is fast and returns a single element; use it when you have an id. querySelector and querySelectorAll accept CSS selectors, so you can use classes, attributes, or combinators. Prefer querySelector when you need one element and querySelectorAll when you need a list (it returns a NodeList; convert to an array with Array.from() if you need array methods).
Event Loop and Async Behavior
JavaScript is single-threaded. The event loop processes the call stack, then pulls from the task queue (and microtask queue) to run callbacks. When you attach an event listener, the handler runs later, when the event fires. This means synchronous code runs to completion before any event handler runs. Understanding this prevents confusion about "when" things execute and helps you reason about timers, fetch callbacks, and user interactions.
For example, if you call setTimeout(fn, 0), fn runs after the current script and any already-queued tasks. The same applies to event handlers: clicking a button doesn't run the handler immediately in the middle of other code; it gets queued and runs when the stack is clear. That's why you can't "block" the UI with a long loop—the browser only gets to process events and repaint when your code returns control.
Event Bubbling and Capture
Events in the DOM have three phases: capture (from root to target), target, and bubble (from target back to root). By default, listeners are registered for the bubble phase. You can use the third argument of addEventListener (true) to listen in the capture phase. Most of the time you'll use bubbling—it's what allows event delegation.
Understanding the order helps when you have nested interactive elements. A click on a button inside a card will first go down the tree (capture), hit the button (target), then bubble back up. Any listener on the card will see the event in the bubble phase and can react (e.g. open the card detail) while the button might handle its own action (e.g. "Add to cart").
Event Delegation
Instead of attaching a listener to every button or row, attach one listener to a parent. When an event bubbles up, check event.target (or event.target.closest('.your-selector')) to see which child was actually clicked. This reduces memory use, works for dynamically added elements, and keeps code simple. Use it for lists, button groups, and tables.
Example: a list of items where each row has a "Delete" button. Instead of attaching a listener to every button, attach one to the list container:
listEl.addEventListener("click", (e) => {
const button = e.target.closest("button[data-delete-id]");
if (!button) return;
const id = button.getAttribute("data-delete-id");
deleteItem(id);
});
New rows added to the list automatically "participate" because the event bubbles to the same container. You only have one listener regardless of how many rows there are.
target vs currentTarget
event.target is the element that triggered the event (the one the user clicked). event.currentTarget is the element the listener is attached to. In event delegation, currentTarget is the parent you attached the listener to; target is the child that was clicked. Use currentTarget when you need a stable reference to the element owning the handler.
A common mistake is using event.target when you mean the container. For example, if the container has padding and the user clicks the padding, target might be the container itself; if they click a child, target is the child. currentTarget is always the element you attached the listener to, so use it when you want to query within the container or access the container's data attributes.
preventDefault and stopPropagation
event.preventDefault() stops the browser's default action (e.g. following a link, submitting a form). The event still bubbles. event.stopPropagation() stops the event from bubbling (or capturing further). Use preventDefault when you want to handle the action yourself (e.g. client-side form submit or single-page navigation). Use stopPropagation sparingly—it can break delegation and analytics. Prefer preventDefault; only stop propagation when you have a clear reason (e.g. a modal overlay that shouldn't close when clicking inside).
Example: a link that should open a modal instead of navigating:
link.addEventListener("click", (e) => {
e.preventDefault();
openModal(e.currentTarget.getAttribute("href"));
});
If you also called e.stopPropagation(), a parent that listens for clicks to close the modal might not see the event. Only add stopPropagation when you've identified a concrete conflict.
Passive Listeners and Performance
For touch and wheel events, the browser may need to wait for your handler to finish before scrolling (to know if you'll call preventDefault). If you never call preventDefault in that listener, you can register it as passive so the browser can scroll immediately and improve responsiveness:
element.addEventListener("touchstart", onTouch, { passive: true });
Use passive when you're only observing (e.g. analytics or measuring) and not preventing the default. If you need to prevent default, omit passive or set it to false.
Removing Listeners and Memory
If you add a listener with addEventListener, remove it with removeEventListener when the element is no longer needed (e.g. when a component unmounts). You must pass the same function reference. Named functions or refs to the same function make this easier; anonymous functions cannot be removed. In single-page apps, forgetting to remove listeners can cause memory leaks and duplicate handlers when the same route is revisited.
Summary
The DOM is a tree of nodes; query it with querySelector / getElementById and cache results when you use them often. Events are processed asynchronously by the event loop. Events bubble (and capture); use event delegation by listening on a parent and using event.target or event.target.closest(). Distinguish target (what was clicked) from currentTarget (what the listener is on). Use preventDefault to override default behavior and stopPropagation only when necessary. Use passive listeners for touch/wheel when you don't prevent default. Mastering the DOM and events unlocks interactive UIs—combine delegation with clear target/currentTarget usage and minimal propagation stopping for maintainable frontend code.
Related articles
- Frontend FundamentalsFetch and Async
Using fetch, async/await, error handling, loading states, and patterns for data loading in the browser.
Read article - Frontend FundamentalsJavaScript Language Basics
Core JavaScript concepts: variables, functions, control flow, objects, arrays, scope, error handling, and modern ES6+ syntax every frontend developer needs.
Read article - Frontend FundamentalsMastering Frontend Fundamentals: A Mock Interview Guide
Prepare for your frontend developer interview with essential concepts in HTML, CSS, React, and JavaScript, as demonstrated in a mock interview.
Read article - Frontend FundamentalsAdvanced JavaScript
Deep dive into the event loop, async patterns, prototypes, closures, functional programming, and JavaScript gotchas every senior developer should know.
Read article