gap·map

Browser APIs

[ junior depth ]

Event bubbling and capturing

When you click a button, the event doesn't fire only "on the button". It travels the DOM tree in three phases: capture (from the root down to the element), target (at the element itself), and bubble (back up to the root). Listeners run in the bubble phase by default; pass { capture: true } to run on the way down instead.

Two properties tell you where you are in that trip:

  • event.target is where the event happened. It stays constant for the whole trip.
  • event.currentTarget is the element whose listener is currently running. It changes at every step.

❌ "currentTarget is the element that was clicked" gets it backwards. The clicked element is target; currentTarget is wherever you attached the handler.

preventDefault vs stopPropagation

The two methods have separate jobs with zero overlap:

  • preventDefault() cancels the default action: the link doesn't navigate, the form doesn't submit. The event keeps bubbling.
  • stopPropagation() stops the walk, so ancestors never hear the event. The default action still happens.

Need both effects? Call both. Neither implies the other.

How event delegation works

Instead of a listener per row, put one listener on the container and let bubbling deliver everything to it:

table.addEventListener("click", (e) => {
  const btn = e.target.closest("button.delete");
  if (!btn) return;                       // click wasn't on (or inside) a delete button
  deleteRow(btn.closest("tr").dataset.id);
});

This is the default pattern for lists, tables, and menus. One handler replaces thousands, and rows added later work without any extra wiring, since the listener was never on the rows. closest() matters because the click usually lands on an icon inside the button; comparing e.target.tagName breaks the day someone adds an <svg>.

addEventListener has an options bag

el.addEventListener("click", handler, { once: true });               // auto-removes after first fire
el.addEventListener("touchmove", handler, { passive: true });        // promise: no preventDefault → browser scrolls immediately
el.addEventListener("click", handler, { signal: controller.signal }); // controller.abort() removes it

passive is a performance contract: the browser doesn't have to wait for your handler before scrolling. signal solves the classic cleanup pain. removeEventListener needs the same function reference, so inline arrows are unremovable without it; one abort() clears everything registered with that signal.

The observer APIs: IntersectionObserver, ResizeObserver, MutationObserver

These replaced "check on a timer" and "do math in a scroll handler":

  • IntersectionObserver answers "is this element visible (in the viewport or a scroll container)?" Lazy images, infinite scroll, view analytics.
  • ResizeObserver answers "did this element's box change size?" It gives you container-aware components without listening to window resize and guessing.
  • MutationObserver answers "did someone change this DOM subtree?" Use it to react to markup you don't render yourself.

They share a shape (new XObserver(callback) then .observe(target)) and a philosophy: the browser watches and calls you asynchronously, in batches, instead of you asking sixty times a second.

What a Web Worker is

JS is single-threaded per page; a Web Worker is a second thread. It has no window, no document, no DOM. It computes, fetches, and talks to the page only through postMessage, and the data you post is copied, not shared. A Service Worker is a different thing wearing the same name: a worker the browser runs between your page and the network, able to intercept requests and answer from a cache. The middle notes cover its lifecycle.

[ middle depth ]

Where delegation breaks

Delegation depends on the event reaching your container listener. Each of these severs that path without an error:

  • stopPropagation() in between. A third-party widget inside a cell stops the click, your table listener never runs, no error anywhere. Escape hatch: register the container listener with { capture: true }, which runs on the way down, before descendants' bubble handlers can eat anything.
  • Events that don't bubble. focus/blur and mouseenter/mouseleave never reach the container. Delegate their bubbling twins focusin/focusout and mouseover/mouseout instead.
  • disabled controls. A click on a disabled button dispatches no event at all. ❌ "The event doesn't bubble" is the wrong model: the event never exists. Delegation can't see what never fires; wrap the button if you need feedback there.

Shadow DOM retargets. Outside a shadow root, event.target is rewritten to the host element, because the browser hides the component's internals from you. So closest("button") finds nothing if the button lives inside a web component. event.composedPath()[0] reveals the real target for open roots. And events dispatched inside a shadow root need composed: true to cross the boundary at all.

Custom events default to invisible

el.dispatchEvent(new CustomEvent("item:selected", {
  detail: { id: 42 },
  bubbles: true,     // default false — without this, delegation never sees it
  composed: true,    // default false — without this, it dies at a shadow boundary
}));

Both defaults are the opposite of what delegation needs. A CustomEvent that "doesn't work" is almost always missing bubbles: true.

When observer callbacks fire

  • IntersectionObserver is async, batched after layout (before paint), and crossing-based: you're called when visibility crosses a threshold, not on every scroll frame. Gotcha: the callback fires once right after observe() with the initial state, often isIntersecting: false, so guard for it. root/rootMargin/threshold are frozen at construction.
  • ResizeObserver fires after layout, before paint, in the same frame, so you can adjust without a flash. It observes a box you choose (content-box default, border-box). If your callback changes the size it observes, the browser breaks the cycle at the frame boundary and reports ResizeObserver loop completed with undelivered notifications. That Sentry regular means "your handler resizes what it watches".
  • MutationObserver runs its callback as a microtask with an array of batched MutationRecords: a thousand synchronous DOM changes produce one callback, after your code finishes. Scope it with config ({ childList, subtree, attributes, attributeFilter: ["class"] }), because subtree: true on body with no filter is a firehose. takeRecords() drains pending records synchronously before disconnect(). Why microtasks run when they do is covered in event-loop.

What postMessage costs

postMessage uses structured clone, a deep copy. Functions and DOM nodes throw; big payloads cost per byte, per message. Transferables (ArrayBuffer, MessagePort, OffscreenCanvas, ImageBitmap) move instead of copy:

worker.postMessage(buf, [buf]);  // zero-copy; buf is now neutered here (byteLength 0)

Workers can fetch, use IndexedDB and WebSockets, and spawn sub-workers; { type: "module" } gives you import. A SharedWorker is one instance shared by all same-origin tabs: each tab talks through its own port, and the worker receives them via onconnect. Whether to move work into a worker at all is a profiling judgment; that question lives in rendering-performance. Cross-window postMessage has its own origin-validation security story, covered in web-security.

The service worker lifecycle

A SW is a proxy the browser installs between your page and the network. Learn the lifecycle before the code, because most production incidents start there:

  1. register. The browser fetches the script. The page that registered it is not controlled until the next navigation (unless clients.claim()).
  2. install. Precache in event.waitUntil(...); a rejected promise discards this version.
  3. waiting. A new version installs but waits until zero clients run the old one. ❌ "Refresh picks up the new SW" fails: old and new page overlap during navigation, so the old SW never hits zero clients. Close the tab, or call skipWaiting() (a loaded gun; see the senior notes).
  4. activate. The old version is gone; delete stale caches here.

Updates: the browser re-fetches the SW script on navigation and treats it as new if byte-different. Interception is one event:

self.addEventListener("fetch", (event) => {
  event.respondWith((async () => {
    const cache = await caches.open("v1");
    const cached = await cache.match(event.request);
    const network = fetch(event.request).then((res) => {
      cache.put(event.request, res.clone());  // clone: body streams read once
      return res;
    });
    return cached ?? network;                 // stale-while-revalidate, as code
  })());
});

You choose a strategy per request: cache-first (immutable assets), network-first (API), stale-while-revalidate (everything in between). HTTP caching headers are a separate layer; see http-caching.

WebSocket vs SSE

WebSocket starts as an HTTP request with Upgrade: websocket, then becomes a persistent two-way framed connection: strings or binary (Blob/ArrayBuffer), sub-protocol negotiation, bufferedAmount as your only backpressure signal. What it does not have is reconnection. Drop the connection and new WebSocket(...), backoff, and re-sync are all your code.

SSE (EventSource) is the opposite trade: a plain HTTP response with Content-Type: text/event-stream that never ends, server-to-client only, text only:

retry: 5000
id: 42
event: price
data: {"symbol":"A","px":101.4}

The browser parses data:/event:/id:/retry: fields and reconnects automatically, sending Last-Event-ID so the server can resume where you left off. That reconnection is the killer feature. Historic weakness: HTTP/1.1 allows ~6 connections per origin, so a few tabs of SSE starved the site. Over HTTP/2, streams multiplex on one connection and the limit disappears. Which one to pick, and what reconnection involves in practice, is in the senior notes.

[ senior depth ]

MutationObserver as a containment tool

The production use of MO is rarely your own DOM. The targets are DOM you don't control: a tag-manager script injecting banners, an A/B tool rewriting your CTA, an extension mangling your form. A subtree observer is your tripwire:

new MutationObserver((records) => {
  for (const r of records)
    for (const node of r.addedNodes)
      if (node.nodeType === 1 && !node.matches?.("[data-ours]")) audit(node);
}).observe(document.body, { childList: true, subtree: true });

Be honest about what this is: detection after the fact. The mutation has already happened by the time your microtask runs, so nothing here prevents anything. Watch the cost too. subtree: true on body sees every mutation in the app, so keep the callback cheap (records are batched; process the array, don't do per-record layout reads) and use attributeFilter when you only care about specific attributes. The same tripwire pattern reverts third-party style tampering: observe { attributes: true, attributeFilter: ["style", "class"] } on the element you're defending.

IntersectionObserver for ad viewability and impression counting

Lazy images are the easy use. Analytics, ad viewability, and impression counting make claims about visibility, and there a wrong config means wrong data:

  • isIntersecting alone means "≥1px crossed". A real viewability claim is ratio + dwell: threshold: 0.5, start a timer on entry, count the impression only if 50% stayed visible ~1s, cancel on exit. One pixel for one frame is not "seen".
  • rootMargin: "200px 0px" moves the boundary outward, which makes it the tool for "start work before the user arrives" (preload the next chunk, warm a request). Thresholds fire earlier relative to real visibility, so keep analytics observers and preload observers separate rather than reusing one config for both jobs.
  • The initial post-observe() delivery reports current state. An unguarded callback counts every below-fold element as an "impression event" with isIntersecting: false, or worse, filters wrong and counts them all.
  • Scale: one observer, many targets. For virtualized/infinite lists, either a sentinel row (unobserve + re-observe as it moves) or per-row observation with unobserve as soon as the row's work is done. A thousand observers with one target each buys the coalescing machinery a thousand times.

Service worker update strategies

The waiting state is a feature. It guarantees one version per session, so a new worker never feeds an old page. Design your update policy around it instead of fighting it:

  • Default: the new SW waits; users get it on the next full navigation. Safe but slow, and fine for most apps.
  • Prompt-to-refresh: detect the waiting worker (updatefound, then installing.state === "installed" with an existing controller), show "new version available", and on click message the worker to skipWaiting(), listen for controllerchange, reload once. Users update in seconds and never run mixed versions.
  • Blind skipWaiting() is only safe when old pages can survive new responses: hashed immutable chunks that the new precache still serves. If the new deploy removes old chunks, an open old-version tab lazy-loads a chunk that no longer exists and white-screens. This failure mode is the classic SW outage.

Kill switch, before you need it. A bad SW doesn't roll back when your servers do: it's installed on the client, answering from cache. Plan the exits. Keep the SW script URL stable forever (an old cached page registers the old URL; a renamed SW is undiscoverable), serve it with a short or no-cache lifetime so a fix propagates in minutes, and keep a tested no-op pass-through worker ready to deploy at that URL (or a registration.unregister() path). If you can't ship a worker that stops intercepting, you don't control your own site anymore.

WebSocket vs SSE vs polling

Whatever you pick, the engineering effort goes into the failure path:

  • Reconnect with jittered exponential backoff. A server restart disconnects every client simultaneously; naive setTimeout(connect, 1000) turns that into a synchronized thundering herd. Backoff spreads retries; jitter desynchronizes them.
  • Reconnection includes state recovery. You missed messages while down. Either the server replays (SSE's Last-Event-ID gives you this for free; over WS you build sequence numbers plus replay) or the client re-fetches a snapshot and resubscribes.
  • Heartbeats, because proxies and LBs kill idle connections silently, and the deadest connection is one that still looks open. SSE comments (: ping) or WS ping frames, plus a client-side timer that reconnects after N seconds without data.
  • Presence ("who's online") is a server-side fan-out/TTL problem; the browser API contributes nothing but the connection.

Choosing. The flowchart is shorter than people think. Data flows server to client only (feeds, notifications, LLM token streams)? SSE: plain HTTP, so every proxy, CDN, and auth middleware already works; auto-reconnect with resume built in; multiplexed over h2. Genuinely bidirectional and latency-sensitive (collab editing, multiplayer, live cursors)? WebSocket, and budget for the reconnect/replay layer plus operational friction: stateful connections pin to servers, complicate deploys and autoscaling, and block bfcache (close on pagehide). Updates every 30s or slower (dashboards, inbox badges)? Poll. Polling is stateless, cacheable, debuggable with curl, and survives every network middlebox ever built. ❌ Interviewers treat "WebSocket, because it's the real-time technology" as a red flag; when the data changes every 30 seconds, polling is the correct answer.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Browser & Network