Search IOCombats

Search challenges, guides, questions and articles

6 Underused Browser APIs and DevTools Techniques That Fix Real Production Bugs
DevToolsDebuggingPerformanceJavaScript APIsCSSFrontend Engineering

6 Underused Browser APIs and DevTools Techniques That Fix Real Production Bugs

By Ghazi Khan | Aug 10, 2026 - 9 min read

Most frontend bugs that make it to production are not logic errors. They are memory that never gets released, network requests that keep running after a component unmounts, deep clones that silently drop data, or a feed that repaints thousands of DOM nodes it does not need to. None of these show up in a typical code review, and none of them are covered well in tutorials, because they only matter at the scale of a real app with real users.

This post walks through six techniques that solve exactly these problems. Each one is something you can use this week, and each one is a fair interview question, because it tests whether you understand what the browser is actually doing, not just whether you can write JSX.

Finding Memory Leaks with the Three-Snapshot Technique

A memory leak in a web app almost always looks the same: you perform some action repeatedly (open and close a modal, navigate between routes, apply a filter), and memory usage climbs and never comes back down. The hard part is not noticing the leak, it is finding which object is being retained and why.

Chrome DevTools' Memory panel has a heap snapshot tool that lets you compare the state of the JS heap at different points in time. The reliable version of this workflow is the three-snapshot technique, originally used by the Gmail team to track down retained DOM nodes:

  1. Baseline snapshot. Get the app into a steady state, then take snapshot 1.
  2. Target snapshot. Perform the suspect action once (open and close the modal, for example), force a garbage collection with the trash-can icon in DevTools, then take snapshot 2.
  3. Final snapshot. Perform the same action again, force GC again, take snapshot 3.

With three snapshots, switch the view to "Objects allocated between snapshot 1 and 2" and compare it against what shows up again between snapshot 2 and 3. Anything present in both deltas survived a garbage collection cycle it should not have survived, which means something is still holding a reference to it. A single snapshot cannot tell you this. It just shows you everything alive at that moment, most of which is legitimate.

Diagram
100%
flowchart LR A["Snapshot 1: Baseline"] --> B["Perform action + force GC"] B --> C["Snapshot 2: Target"] C --> D["Repeat action + force GC"] D --> E["Snapshot 3: Final"] C -. "Delta 1 to 2" .-> F["Compare deltas"] E -. "Delta 2 to 3" .-> F F --> G["Objects in both deltas = leaked"]
visualized byIOCombats

The most common root cause behind this pattern in React and Vue apps is an event listener or subscription that was never cleaned up:

// Leaks: the listener closes over `state` and is never removed
useEffect(() => {
  window.addEventListener('resize', () => setWidth(window.innerWidth));
}, []);

// Fixed: return a cleanup function
useEffect(() => {
  const handleResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

If you only remember one thing from this section, remember this: a single heap snapshot tells you what is alive, but only a snapshot diff across a repeated action tells you what should have died and didn't.

Composable Cancellation with AbortController and AbortSignal.any()

AbortController is well known for cancelling fetch() calls, but most usages only handle one cancellation source at a time. Real components usually need to cancel a request for more than one reason: the component unmounted, the user navigated away, or the request took too long.

AbortSignal.any() solves this by combining multiple signals into one. It has been supported across all major browsers since March 2024, with roughly 90% global support as of 2026, so it is safe to use directly in most production codebases.

Diagram
100%
flowchart LR T["Timeout signal<br/>AbortSignal.timeout()"] --> AS["AbortSignal.any([...])"] U["Unmount signal<br/>controller.abort() on cleanup"] --> AS X["External signal<br/>caller-provided"] --> AS AS -- "aborts the moment any one fires" --> F["fetch(url, { signal })"]
visualized byIOCombats
function fetchUserProfile(userId, { timeoutMs = 8000, externalSignal } = {}) {
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  const combinedSignal = externalSignal
    ? AbortSignal.any([timeoutSignal, externalSignal])
    : timeoutSignal;

  return fetch(`/api/users/${userId}`, { signal: combinedSignal }).then(
    (res) => {
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      return res.json();
    },
  );
}

In a component, wire the unmount cleanup to the same controller so every cancellation path (timeout, unmount, explicit cancel) is handled by one code path instead of three:

useEffect(() => {
  const controller = new AbortController();
  fetchUserProfile(userId, { externalSignal: controller.signal })
    .then(setProfile)
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });
  return () => controller.abort();
}, [userId]);
ApproachHandles timeoutHandles unmountComposable
Single AbortControllerManualYesNo
AbortSignal.timeout() aloneYesNoNo
AbortSignal.any([...])YesYesYes

structuredClone() vs the JSON Deep Clone Antipattern

JSON.parse(JSON.stringify(obj)) has been the default deep-clone trick in JS for years, and it is quietly broken. It drops undefined values, converts Date objects to strings, throws on circular references, and silently discards functions, Map, and Set.

structuredClone(), available natively in the browser and in Node.js since 2022, implements the same structured clone algorithm the browser already uses internally for postMessage() and IndexedDB. It correctly handles Date, Map, Set, typed arrays, and circular references.

const original = {
  createdAt: new Date(),
  tags: new Set(['urgent', 'review']),
  meta: undefined,
};

JSON.parse(JSON.stringify(original));
// { createdAt: "2026-08-10T00:00:00.000Z", tags: {} }  <- Date became a string, Set is gone, meta is gone

structuredClone(original);
// { createdAt: Date, tags: Set(2), meta: undefined }  <- all types preserved
MethodPreserves Date/Map/SetHandles circular refsClones functions
JSON.parse(JSON.stringify())NoNo (throws)No (drops silently)
structuredClone()YesYesNo (throws)
lodash.cloneDeep()YesYesYes
Diagram
100%
flowchart TB IN["Input: Date, Set, undefined field"] --> M1 IN --> M2 IN --> M3 subgraph M1["JSON.parse(JSON.stringify())"] direction TB a1["Date becomes a string"] a2["Set becomes an empty object"] a3["undefined field is dropped"] end subgraph M2["structuredClone()"] direction TB b1["Date preserved"] b2["Set preserved"] b3["undefined preserved"] end subgraph M3["lodash.cloneDeep()"] direction TB c1["Date preserved"] c2["Set preserved"] c3["Functions also cloned"] end
visualized byIOCombats

Use structuredClone() as the default for cloning state before mutation (a common pattern in Redux reducers and undo/redo stacks) and reach for lodash.cloneDeep only in the rare case where you actually need to clone functions.

CSS :has() for Parent and Sibling Selection

Before :has(), selecting a parent based on its children required JavaScript, because CSS selectors could only ever target descendants, never ancestors. :has() is a relational pseudo-class that lets a selector match an element if a condition inside it is true. It has been supported in all major browsers since December 2023 (Firefox was last to ship it), and sits above 93% global support in 2026.

A common real case: highlight a form field's container only when it contains an invalid input, without touching JavaScript or adding a class via a re-render:

.form-field:has(input:invalid) {
  border-color: var(--color-error);
  background-color: var(--color-error-bg);
}

.form-field:has(input:focus) {
  border-color: var(--color-accent);
}

Another common one: style a card differently if it contains an image, entirely in CSS:

.card:has(img) {
  grid-template-rows: 200px auto;
}
Diagram
100%
flowchart TB subgraph forward["Traditional combinators: forward only"] direction LR p1[".form-field"] --> c1["input"] end subgraph backward[".form-field:has(input:invalid)"] direction LR c2["input:invalid"] -- "condition matched here, styles the parent" --> p2[".form-field"] end
visualized byIOCombats

This matters in interviews because it is a direct test of whether you understand CSS selector direction. Every other combinator (>, +, ~, descendant space) selects forward. :has() is the only one that lets you select backward, and knowing that is usually the actual question being asked.

content-visibility: auto for Rendering Performance

Long pages (feeds, dashboards, changelogs) spend rendering time on content that is nowhere near the viewport. content-visibility: auto tells the browser to skip layout and paint work for an element until it is close to being visible, similar in spirit to virtualization libraries, but implemented natively by the rendering engine instead of by JavaScript recalculating a visible window.

.feed-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 300px; /* placeholder height while off-screen */
}

The contain-intrinsic-size line is not optional in practice. Without it, the browser assumes an off-screen element has zero height, which causes the scrollbar to jump around as content scrolls into view. Giving it an estimated size avoids that layout shift.

Diagram
100%
flowchart TB O1["Items 1-3: off-screen above<br/>placeholder height only, no layout/paint"] V["Items 4-5: in viewport<br/>fully rendered, layout + paint"] O2["Items 6-10: off-screen below<br/>placeholder height only, no layout/paint"] O1 --> V --> O2
visualized byIOCombats

Support has been in Chrome and Edge since 2020, Safari added it in Safari 18, and Firefox turned it on by default in Firefox 125, so as of 2026 it works across all three major engines. It is a strong first move for a long list before reaching for a virtualization library, because it requires no JavaScript and no changes to how the list is rendered.

Scheduling Non-Urgent Work with requestIdleCallback

Not all work needs to happen immediately. Analytics batching, prefetching, and non-critical DOM updates can wait until the browser has spare time in the current frame. requestIdleCallback schedules a callback to run during that idle time, and gives the callback a deadline object so it can stop early if the browser needs to do more important work.

function processQueueInIdleTime(queue) {
  requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && queue.length > 0) {
      processItem(queue.shift());
    }
    if (queue.length > 0) {
      processQueueInIdleTime(queue);
    }
  });
}
Diagram
100%
flowchart LR A["Input handling"] --> B["Style and Layout"] B --> C["Paint"] C --> D["Idle period:<br/>requestIdleCallback runs here"] D -. "queue not empty and deadline.timeRemaining() = 0" .-> E["Reschedule into next frame's idle period"]
visualized byIOCombats

The caveat: Safari does not support requestIdleCallback in any stable release as of 2026. It sits behind a WebKit feature flag that almost no real user has enabled. Any production use needs a fallback:

const scheduleIdleWork =
  typeof requestIdleCallback === 'function'
    ? requestIdleCallback
    : (cb) =>
        setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1);

This is a good interview signal both ways: knowing the API shows you understand browser scheduling, and knowing the Safari gap shows you actually ship things instead of only reading about them.

Practical Takeaway

None of these six techniques require a new framework or a rewrite. The three-snapshot method changes how you approach a memory leak report from "add console.logs and guess" to a repeatable process. AbortSignal.any() and structuredClone() remove entire categories of bugs (stale requests, silently corrupted clones) by using the platform correctly instead of working around it. :has() and content-visibility move work out of JavaScript and into the rendering engine, where it is faster by default. requestIdleCallback, used correctly with a fallback, makes non-urgent work actually non-blocking.

In an interview, the strongest answer to "how would you debug X" or "how would you optimize Y" is rarely a library name. It is naming the underlying browser mechanism and showing you know its limits.

Conclusion

These are not exotic APIs. They are already shipped, already supported in the browsers your users run, and already solve problems most teams work around with more code instead of less. Pick one you have not used yet and apply it to a real bug this week.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge