skip to content

DOM and Browser APIs

The narrow doorway from the JavaScript language into the browser host: how a script reaches into the document, wires up events, and notices which globals actually exist where it runs. Interviewers use this to check you can manipulate a page and reason about delegation without hand-waving, before pushing you into platform-depth territory.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

14

What is event delegation in the DOM, and why does a single delegated handler still work for list items that are added after the handler was attached?

level: juniorimportance: must knowfreq 78%

answer

  1. one listener, many children
  2. the container owns the handler
  3. identify the child from the event
  4. closest() maps the hit to a row
  5. new rows need no wiring at all

basics

~20 s

Event delegation puts one listener on a container instead of one per child and identifies the child from the event object. Items added later are covered automatically, because the listener lives on the container, not on each child.

solid answer

~50 s

Event delegation means registering a single listener on a container — say the `<ul>` — instead of one listener on every `<li>`, and then working out inside the handler which child the interaction actually concerned, usually with `event.target.closest('li')`. Because the listener belongs to the container and the container stays put, any `<li>` you append later is handled with no extra wiring: there is nothing to attach when a row appears and nothing to unregister when it goes away. That is why it shows up in list, table and infinite-scroll code, where rows arrive and disappear constantly. It also keeps registration at one call instead of N, and puts all the dispatch logic in one readable place. The guard matters though — check that `closest()` actually found a row inside your own container before acting on it.

code

javascript · 18 lines
javascript
const list = document.createElement('ul');
document.body.append(list);

list.addEventListener('click', (event) => {
  const item = event.target.closest('li[data-id]');
  if (!item || !list.contains(item)) return;
  console.log('activated', item.dataset.id);
});

function addRow(id, label) {
  const li = document.createElement('li');
  li.dataset.id = id;
  li.textContent = label;
  list.append(li);
}

addRow('1', 'Write tests');
addRow('2', 'Ship it');

go deeper

for a junior

Be able to state the idiom in one sentence — one listener on the container, identify the child inside the handler — and write it for a list of items with closest().

for a middle

Explain how the handler recovers the row from whatever element the interaction landed on, why closest() beats a tagName check, and why the two guards (null result, container.contains) belong in every delegated handler.

for a senior

Show judgment about where the listener lives: the nearest ancestor that survives re-renders, not document, and be ready to explain what happens to the wiring when the container itself is replaced.

for a principal

Frame delegation as a boundary between rendering and behaviour — rendering code emits markup with declarative data-action hooks, behaviour lives in one dispatch table — and argue when that indirection is worth the loss of local readability.

## What the pattern actually is Event delegation is a wiring decision, not an API. Instead of registering a handler on every element a user can interact with, you register one handler on an element that contains them all, and inside that handler you determine which descendant the interaction concerned. For a to-do list the listener goes on the `<ul>`; for a data grid, on the `<tbody>` or the wrapping `<div>`; for a toolbar, on the toolbar element itself. ```js const list = document.querySelector('#todos'); list.addEventListener('click', (event) => { const item = event.target.closest('li[data-id]'); if (!item || !list.contains(item)) return; console.log('activated item', item.dataset.id); }); ``` That is the whole idiom: one registration, one guard, one dispatch. Everything else — how many rows exist, when they were created, how often they are replaced — is irrelevant to the wiring. ## Why items added later are covered The listener is a property of the container, not of its children. When you append a new `<li>`, you create a node your setup code has never touched — and it does not need to be touched, because the element observing the interaction is still the container, and the container was wired once at startup. The event object handed to your handler carries a reference to the innermost element the interaction landed on, so the handler can identify the new row even though it did not exist when the handler was written. The practical consequence is a separation of duties. The code that renders rows becomes a pure "produce DOM" function with no knowledge that rows are clickable; the code that defines behaviour sits in one place next to the container. Removing a row is symmetric: it takes no per-row wiring away with it, because there was none. ## Identifying the right child The naive check `event.target.tagName === 'LI'` is fragile, because the interaction usually lands on something *inside* the row — a `<span>`, an icon, a nested `<button>`. `Element.prototype.closest(selector)` walks from the element upwards and returns the first ancestor-or-self that matches, which is exactly the mapping you want: "given whatever was hit, which row is it in?" `Element.prototype.matches(selector)` is the exact-match variant when you know the interaction lands directly on the element. Two guards are worth having by default. First, `closest()` returns `null` when nothing matches, so a delegated handler that acts on the result unguarded throws on interactions with the container's own padding. Second, `closest()` does not stop at your container — in nested lists it can return a row belonging to an outer list, so `container.contains(row)` keeps the dispatch honest. A common refinement is to mark rows and controls with a data attribute and dispatch on it: `event.target.closest('[data-action]')` plus a lookup table keyed by `dataset.action`. That turns a growing `if/else` chain into a flat map of behaviours and makes the markup declare what each control does. ## Where to attach the listener Pick the nearest ancestor that outlives the children. `document` works but is coarse: every such listener participates in every interaction on the page, and one careless handler can affect unrelated widgets. A listener registered on a container dies with that container, so if your update code replaces the container wholesale rather than emptying it, the wiring disappears with it — attach one level up, on the element that never gets replaced. ## What it costs and when to skip it Delegation trades per-element setup for per-event lookup: one registration and one short ancestor walk per interaction, instead of N registrations and none. For a list of five static buttons that never change, per-element handlers are simpler and the tradeoff is not worth reasoning about. Delegation earns its place when the set of children is large, changes often, or is produced by code that should not have to know about behaviour at all.

  • If your update code replaces the whole container element on each render, what happens to the delegated handler?
    It goes away with the old container — the listener was registered on that node, and the replacement is a different node with no wiring. The fix is to attach to an ancestor that survives every render, or to empty and refill the same container instead of replacing it.
  • How would you delegate when a container holds several different kinds of controls?
    Mark each control with a data attribute naming its action and dispatch through a lookup table: `const el = event.target.closest('[data-action]')`, then call `handlers[el.dataset.action]`. That keeps the handler flat as behaviours are added, and lets the markup declare what each control does instead of hiding it in a growing if/else chain.
  • When would you still attach a handler per element instead of delegating?
    When the set is small and static, or when the element genuinely needs per-element setup that a shared handler cannot express. For five fixed buttons, direct handlers read better and cost nothing. Delegation pays off when children are numerous, are created and destroyed often, or are produced by rendering code that should stay behaviour-free.

It is a receptionist for the whole floor rather than a doorbell on every office door: visitors are received at one desk and routed onwards, and a new office opening down the hall needs no new doorbell.

saying these in an interview costs you the question

  • Thinks delegation means attaching handlers in a loop over children
  • Says newly added items still need the handler re-attached
  • Reads event.target directly and assumes it is the row
  • Believes a handler per item performs better than one delegated handler
  • Attaches everything to document without considering a nearer container

context

open as a page

A script whose first line is `const el = document.body` runs fine in a browser tab but throws `ReferenceError: document is not defined` under `node script.js`. Why is the DOM missing in Node, and what globals does Node supply instead?

level: juniorimportance: must knowfreq 78%

basics

~20 s

The DOM is not part of JavaScript. ECMAScript defines only the core language and built-ins such as Object, Array and Promise; document and window are supplied by the browser host. Node is a different host and supplies process, Buffer and its module globals instead.

open as a page

Why does calling .map() on the result of document.querySelectorAll() throw, and how do you get array methods over the elements it returns?

level: juniorimportance: must knowfreq 66%

basics

~20 s

document.querySelectorAll returns a NodeList, not an Array. It is array-like and iterable and does have forEach, but it does not inherit from Array.prototype, so map, filter and reduce are missing. Convert with Array.from(nodes) or [...nodes].

open as a page

What is the difference between setting an element's textContent and setting its innerHTML in the browser DOM, and when does that difference cause a bug?

level: juniorimportance: must knowfreq 78%

basics

~20 s

textContent treats the assigned string as literal text and shows it exactly as written; innerHTML runs it through the HTML parser and builds real nodes from it. Assigning untrusted data to innerHTML turns that data into page structure.

open as a page

A <ul> has a click handler that does `if (event.target.tagName === 'LI') { ... }`, but each <li> wraps its label in a <span>. Why does clicking the label do nothing, and how do you fix the dispatch?

level: middleimportance: must knowfreq 62%

basics

~20 s

event.target is the innermost element the click landed on — the span, not the li — so the tagName check fails. Match with event.target.closest('li') instead, and verify the result is inside your own container before acting on it.

open as a page

How do you write a JavaScript module that uses browser-only globals such as `document` but is also imported by server code running in Node, so it does not crash on either host?

level: middleimportance: must knowfreq 68%

basics

~20 s

Never touch host globals while the module is being evaluated. Move every browser access inside functions that only the browser calls, and guard those accesses with a capability check such as typeof document !== 'undefined' so importing the module on the server is always harmless.

open as a page

In the browser DOM, how do an element's HTML attributes relate to its JavaScript properties, and why can input.value disagree with input.getAttribute('value')?

level: middleimportance: must knowfreq 62%

basics

~20 s

Attributes are the strings the HTML parser read from the markup; properties are the live state of the element object in JavaScript. Most properties reflect their attribute both ways, but a form control's value attribute is only the initial value.

open as a page

In a delegated list handler, why keep per-item state in a JavaScript Map captured by the handler's closure rather than storing it in data-* attributes on the elements?

level: middleimportance: should knowfreq 44%

basics

~20 s

Dataset values are strings, so state stored in the DOM must be parsed on every read and is lost whenever the element is re-rendered. Keep a Map in the handler's closure keyed by a stable id, and put only that id in the markup.

open as a page

In a Node ES module (a `.mjs` file, or a `.js` file in a package with `"type": "module"`), why is `__dirname` not defined, and how do you get the current file's directory instead?

level: middleimportance: should knowfreq 48%

basics

~20 s

__dirname, __filename, require, module and exports are parameters that Node's CommonJS wrapper injects into each CJS file. ES modules have no such wrapper, so those names do not exist. ESM gets import.meta.url instead — a file: URL string you convert to a path.

open as a page

What does `setTimeout` return in a browser versus in Node, and what code breaks when you assume the two are the same?

level: middleimportance: should knowfreq 42%

basics

~20 s

Browsers return a positive integer id. Node returns a Timeout object with methods such as ref, unref and refresh. Code breaks when it treats the handle as a number — comparing it, serializing it, or storing it as a numeric id in shared state.

open as a page

How do you read and write a data-* attribute from JavaScript through an element's dataset property, and what surprises people about the values and the names?

level: middleimportance: should knowfreq 45%

basics

~20 s

The dataset property maps data-* attributes to camelCase keys, so data-user-id is read as el.dataset.userId. Every value is a string, because attributes only hold strings, and a missing key reads as undefined rather than null.

open as a page

A grid re-renders 5,000 rows on every data update and attaches a click listener to each row afterwards; clicks feel sluggish and the tab's memory grows over time. How does moving to one delegated listener on the container change that, and what does it not fix?

level: seniorimportance: should knowfreq 40%

basics

~20 s

A single listener on the container replaces 5,000 registrations and 5,000 closures per render with one, so the post-render wiring pass disappears. It does not make building 5,000 rows cheaper — that render, not the listeners, is usually the dominant cost.

open as a page

Node now provides globals that were once browser-only, such as `fetch`, `AbortController`, `URL` and `structuredClone`. How should shared JavaScript code decide what it is allowed to call?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Test for the capability you are about to use, not for the host you think you are on. Checks like typeof fetch === 'function' stay correct as runtimes converge, while inferring a host from window or process goes stale and is wrong in workers, test environments and non-browser, non-Node runtimes.

open as a page

A widget refreshes its list by assigning container.innerHTML = newMarkup on every update. What starts breaking as the widget grows, and how do you reason about it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Assigning innerHTML discards every existing child and parses replacement nodes, so anything held on the old nodes is lost: attached event listeners, typed input values, checkedness, focus, selection and scroll position, plus any JavaScript references, which now point at detached nodes.

open as a page