Topics
v6.0.0
On this page · sections

JavaScript DOM Events, Scripts & Modules

Can you explain how a click reaches a handler, when a script runs, and how ESM bindings stay live?

Frontend JavaScript sits on three host systems: the DOM event path, the script loader, and the module graph. Interviews mix them because a bug can be any of the three.

These questions test:

  • Capture, target, bubble
  • Delegation
  • async vs defer vs type="module"
  • ESM vs CommonJS
  • Live bindings and cycles

Questions

DOM

  1. Capture, Target, Bubble
  2. Delegation and stopPropagation

Loading

  1. async vs defer vs Modules

Modules

  1. ESM vs CommonJS

Senior-Level Follow-ups

  1. preventDefault vs Stop Propagation
  2. Live Bindings and Cycles
  3. Listener Hygiene

DOM

1. Capture, Target, Bubble

Why This Matters

The path is not “the element then parents.” Capture runs root → target first.

Problem

In which order do listeners fire for a click on a child?

Example

window capture ↓ document capture ↓ … ancestors … capture ↓ target ← target phase bubble ↑ … ancestors … bubble ↑
parent.addEventListener("click", fn, true); // capture parent.addEventListener("click", fn); // bubble (default)

target listeners run between capture and bubble, in registration order for that element.

Interview Answer

“Events travel capture down, then the target, then bubble up. The third argument or { capture: true } selects the phase. Most app handlers use bubble. Capture is for intercepting before children.”

2. Delegation and stopPropagation

Why This Matters

A list of 1,000 rows should not get 1,000 listeners.

Problem

How does delegation work, and what does stopPropagation change?

Example

list.addEventListener("click", (event) => { const row = event.target.closest("[data-id]"); if (!row || !list.contains(row)) return; select(row.dataset.id); });

The listener sits on a stable ancestor. New rows work automatically.

event.target is the deepest node. event.currentTarget is the node whose listener is running.

stopPropagation ends the remaining path. stopImmediatePropagation also skips other listeners on the same node.

Interview Answer

“Delegation listens on an ancestor and uses closest to find the item. It scales with DOM size, not item count. I stop propagation only when I have a real reason — it hides other listeners.”

Loading

3. async vs defer vs Modules

Why This Matters

Script order is a first-paint and a race-condition question.

Problem

When does each script execute, and which preserves order?

Example

KindFetchExecuteOrder
Classic in <head>blocks parseimmediatelydocument order
asyncparallelwhen readynot ordered
deferparallelafter parsedocument order
type="module"parallelafter parsedependency graph
<script async src="pixel.js"></script> <script defer src="app.js"></script> <script type="module" src="entry.js"></script>

async is for independent snippets. defer / modules are for app code that needs DOM and a stable order.

Modules are deferred by default, always strict, and evaluate once.

Interview Answer

“Classic scripts block parsing. async runs as soon as the file is ready, unordered. defer and modules wait until the document is parsed and keep order. I put app entry on defer or type='module'.”

Modules

4. ESM vs CommonJS

Why This Matters

Bundlers hide this until a circular import or a dual-package library breaks in Node.

Problem

What is the difference in loading, exports, and this?

Example

// ESM import { add } from "./math.js"; export function scale(n) { return add(n, n); }
// CJS const { add } = require("./math"); module.exports.scale = (n) => add(n, n);
ESMCJS
LoadStatic, async graphSync require
ExportsLive bindingsCopied values on require
this at top levelundefinedmodule.exports
FileOne evaluationSame, cached on require.cache

import() is the dynamic ESM form and returns a Promise.

Interview Answer

“ESM is static and async with live bindings. CJS is synchronous and snapshots module.exports. I do not mix them casually: default interop (import x from 'cjs') is a bundler/Node policy, not a language identity.”

Senior-Level Follow-ups

5. preventDefault vs Stop Propagation

preventDefault cancels the browser default (navigate, submit, check). It does not stop other listeners.

stopPropagation hides the event from other nodes. You can still preventDefault after a stop if the same listener does both.

{ passive: true } on touch/wheel tells the browser you will not cancel — better scroll performance.

6. Live Bindings and Cycles

// a.js import { n } from "./b.js"; export const m = 1; // b.js import { m } from "./a.js"; export const n = m + 1;

ESM can cycle. The imported binding is a live view. Reading it before the exporting module finishes initializing can throw or yield undefined depending on TDZ.

CJS cycles often give a partial exports object. That is why “works in CJS, TDZ in ESM” appears in migrations.

7. Listener Hygiene

Every addEventListener needs a matching remove, or a signal:

const controller = new AbortController(); el.addEventListener("click", onClick, { signal: controller.signal }); controller.abort();

Third-party scripts (async pixels) should not be ordered with app bundles. Give them async and a CSP.

Final Interview Strategy

1. Capture then target then bubble 2. Delegate to a stable ancestor 3. async unordered; defer/module ordered after parse 4. ESM live bindings vs CJS snapshot 5. preventDefault ≠ stopPropagation 6. Cycles need initialization order 7. Remove listeners or pass AbortSignal

Senior-level signal: Connect the click path, the script schedule, and the module graph. Production bugs usually sit on one of those three timelines.

Last updated on