Stop Debouncing Resize. The Browser Already Watches It.
DEV Community

Stop Debouncing Resize. The Browser Already Watches It.

Stop Debouncing Resize. The Browser Already Watches It.

Your dashboard has a sidebar that collapses into icons under 900px. Someone on the team drags the browser window slowly from a wide monitor to a narrower one, testing the responsive layout, and watches the sidebar flicker - collapse, expand, collapse, expand - three or four times before it settles. You go looking. The bug isn't in the CSS. The CSS breakpoint is clean, one @media rule, no flicker there at all. The flicker is coming from a resize listener in JavaScript, checking window.innerWidth to decide whether to also swap out a heavier chart component for a simplified mobile one. And that check is running on every single resize event - which, mid-drag, can fire more than sixty times a second.

Why the Current Approach Fails

Adding a debounce slows the flicker but doesn't fix what the check is asking. You're still comparing a raw pixel number against 900 on every settle, and a scrollbar appearing or disappearing shifts innerWidth by 15px and triggers a false flip at the boundary - a false transition your CSS breakpoint, evaluated by the browser's own layout engine, never has.

The core issue is that you rebuilt a media query in JavaScript, badly, when the browser already had a native way to ask it the same question. The code that got you here looks like this:

let isMobile = window.innerWidth < 900;
window.addEventListener("resize", debounce(() => {
  const nowMobile = window.innerWidth < 900;
  if (nowMobile !== isMobile) {
    isMobile = nowMobile;
    renderChart(isMobile);
  }
}, 150));

This subscribes to every resize event on the page, then inside every callback does the comparison the CSS engine already performed elsewhere, with a magic number (900) that must be kept in sync with a breakpoint value living in a stylesheet you don't touch from this file. Changing the CSS breakpoint to 960 for a redesign and forgetting this line means the chart swap now happens at the wrong width for months.

The Correct Solution: Use window.matchMedia()

The browser has been able to answer "does this media query currently match, and tell me the moment that changes" since long before this bug shipped. Instead of polling innerWidth, use window.matchMedia(), which returns a live object rather than a one-off boolean:

const mobileQuery = window.matchMedia("(max-width: 899px)");
console.log(mobileQuery.matches); // true or false, right now mobileQuery.matches;

mobileQuery.addEventListener("change", (event) => {
  renderChart(event.matches);
});

That's the whole fix. No debounce, no innerWidth, no magic number duplicated from your stylesheet - the number lives in exactly one place, the query string, and you can literally copy it out of your CSS. The change event fires exactly once at the instant the query's truth value flips from false to true or back. Drag the window across the boundary ten times and you get ten events - not six hundred.

Supporting Media Features

A media query isn't only about width. Other valid media features include:

  • prefers-color-scheme
  • prefers-reduced-motion
  • prefers-contrast
  • hover
  • pointer

None of these have anything to do with the size of the window. For example:

const wantsReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
if (wantsReducedMotion.matches) {
  skipEntranceAnimation();
}

wantsReducedMotion.addEventListener("change", (event) => {
  // fires the moment the reader flips the OS setting - no reload needed
  enabled = !event.matches;
});

There is no resize-shaped event for "the user just turned on reduced motion in their OS settings while your tab was open." There's no window dimension that encodes that. If your JavaScript-driven animation logic - the kind CSS @media (prefers-reduced-motion: reduce) can't reach because it's timed with requestAnimationFrame, not a transition - never checks this, you've built an accessibility feature that only works for people who happened to have the setting on before your page loaded.

Broader Pattern: Listen for State Changes Directly

The pattern here isn't specific to breakpoints. Whenever you catch yourself polling a raw value on every tick of a noisy event - scroll, resize, mousemove - and then hand-comparing it against a threshold to derive a state that only has two or three values, stop and ask whether the platform already has a named event for the state itself.

Threshold-crossing is a narrower, cheaper thing to subscribe to than "everything moved," and browsers have been quietly shipping these narrower events for longer than most of us have been checking for them. Next time you write a debounce around a resize or scroll handler, that debounce is a tell - it's evidence you're computing a state change from motion instead of listening for the state change directly.

Additional Considerations

  • Cleanup: If you create a matchMedia listener inside a component that unmounts, remove it the same way you'd remove any other event listener, or you'll leak a callback that keeps firing against a DOM tree that's already gone.

  • Deprecated APIs: MediaQueryList used to only support addListener() / removeListener() - an older, non-standard pair of methods that predate MediaQueryList implementing the standard EventTarget interface. Use addEventListener("change", …) / removeEventListener("change", …) going forward; the same object, the standard event methods every other DOM node already gives you.

  • Interactive Playground: The article includes an interactive playground where you can test the concepts live. It wires up both a naive resize counter and a matchMedia change counter side by side - drag your actual browser window and watch one number climb dozens of times faster than the other for the exact same physical motion. It also has a live dashboard of prefers-color-scheme and prefers-reduced-motion that updates the second you flip them in your OS settings, no reload, plus a box where you can type any query of your own and watch it live.

Key Takeaways

Issue Wrong Approach Right Approach
Trigger Poll window.innerWidth on every resize Use window.matchMedia().addEventListener("change")
State source Raw pixel comparison Native media query result
Performance Debounce reduces frequency but not accuracy Single event per state transition
Extensibility Hard-coded magic number Query string lives in CSS, shared everywhere

The bottom line: the browser already knows when the media conditions change. Don't reinvent that knowledge with JavaScript polling. Let the platform handle the state for you.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.