Back to Browser Internals
Browser Internals
medium
mid

How would you approach DOM manipulation, event listeners, and styling using vanilla JavaScript?

Use modern DOM APIs (`querySelector`, `closest`, `dataset`, `classList`), prefer event delegation, batch DOM writes after reads to avoid layout thrash, scope styles via CSS classes/custom properties not inline, clean up listeners with AbortController on teardown. Keep DOM operations declarative-ish: template via `<template>` or DOMParser, swap entire subtrees instead of micromanaging.

5 min read·~12 min to think through

Vanilla JS is the substrate beneath every framework. Knowing it well makes you a better React/Vue dev too.

Selecting elements

js
document.querySelector("#header");                 // one
document.querySelectorAll(".card");                // NodeList (forEach, no map)
const items = [...document.querySelectorAll("li")]; // make it an Array
e.target.closest("[data-id]");                     // walk up to matching ancestor

Don't use getElementById etc. except in hot loops where the slightly faster path matters.

Creating elements

js
// Direct
const el = document.createElement("div");
el.className = "card";
el.textContent = "hello";
parent.append(el);

// From a template
const tpl = document.createElement("template");
tpl.innerHTML = `<article class="card"><h2></h2><p></p></article>`;
const node = tpl.content.firstElementChild.cloneNode(true);
node.querySelector("h2").textContent = data.title;
node.querySelector("p").textContent = data.body;
parent.append(node);

<template> is the right shape for repeated render. innerHTML for simple chunks (mind XSS).

Reading / writing safely

  • textContent for text (XSS-safe).
  • innerHTML for HTML strings (sanitize first).
  • setAttribute("data-id", id) or el.dataset.id = id.
  • classList.add / remove / toggle / contains.

Event listeners

js
const ac = new AbortController();
button.addEventListener("click", onClick, { signal: ac.signal });
window.addEventListener("resize", onResize, { signal: ac.signal });

// On teardown:
ac.abort();   // removes all listeners attached with this signal

AbortController is the cleanest way to clean up many listeners at once.

Delegation

js
list.addEventListener("click", (e) => {
  const item = e.target.closest("[data-id]");
  if (!item) return;
  handle(item.dataset.id);
});

One listener for N children; works for dynamically added rows.

Avoiding layout thrash

js
// BAD — read after write per item
items.forEach((el) => { el.style.width = el.offsetWidth + 10 + "px"; });

// GOOD — batch reads, then writes
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] + 10 + "px"; });

Mixing reads and writes in a loop forces sync layout per iteration.

Styling

  • Toggle classes, don't set inline styles for state changes.
  • Use CSS custom properties for dynamic values that need JS-driven updates: el.style.setProperty("--x", ${x}px).
  • Avoid element.style.cssText = ... — clobbers existing styles.

Animation

js
requestAnimationFrame(() => {
  el.style.transform = `translateY(${y}px)`;
});

For smooth animations, use transform + opacity (composite-only), tie to rAF.

Forms

js
const form = document.querySelector("form");
form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(form));
  await save(data);
});

FormData reads all named inputs; Object.fromEntries builds the object.

Mutation observation

js
new MutationObserver((mutations) => { ... }).observe(target, { childList: true, subtree: true });

For watching DOM changes you didn't make. Don't poll.

IntersectionObserver / ResizeObserver

For lazy load, infinite scroll, container queries' polyfill — much better than scroll/resize event spam.

Common mistakes

  • One listener per row (use delegation).
  • innerHTML with user input (XSS).
  • Inline style mutation for state (use classes).
  • Long synchronous loops blocking paint.
  • Reading layout inside a write loop.

Interview framing

"Modern selectors (querySelector, closest), event delegation with AbortController for cleanup, batch DOM reads/writes to avoid layout thrash, CSS classes + custom properties for styling (not inline style mutation for state), <template> and cloneNode for repeated render. FormData + Object.fromEntries for form data. Observers (Mutation, Intersection, Resize) instead of polling. Watch for XSS when using innerHTML — prefer textContent. Animate via transform + opacity inside rAF for 60fps."

Follow-up questions

  • How does AbortController help with listeners?
  • Why prefer classes over inline styles?
  • When would you use IntersectionObserver?

Common mistakes

  • innerHTML with user input.
  • Layout thrash from read-after-write.
  • Polling instead of observers.
  • Manual style mutations for state.

Performance considerations

  • Layout thrash, delegation, observers, rAF — all about keeping the main thread free.

Edge cases

  • Shadow DOM scoping.
  • Custom elements lifecycle.
  • Touch events vs pointer events.

Real-world examples

  • Stripe Elements, htmx, Web Components, classic jQuery patterns rewritten in vanilla.

Senior engineer discussion

Seniors reach for delegation and observers reflexively, batch DOM work, and treat XSS as a default concern when touching innerHTML.

Related questions