DEV Community

Cover image for Stop Debouncing Resize. The Browser Already Watches It.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

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.

You add a debounce. The flicker slows down but doesn't fully go away, because debouncing delays the check, it 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 trips the comparison right at the boundary — a false flip your CSS breakpoint, evaluated by the browser's own layout engine, never has.

The bug isn't the debounce delay. It's that you rebuilt a media query in JavaScript, badly, when the browser already had a native way to ask it the same question.

The obvious fix, and why it's still wrong

Here's roughly the code that got you here:

let isMobile = window.innerWidth < 900;

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

It reads fine. It even mostly works. But look at what it's actually doing: subscribing to every resize event on the page, then, inside every one of those callbacks, doing the comparison the CSS engine already did somewhere else, with a magic number (900) that has to be kept in sync with a breakpoint value that lives in a stylesheet you don't touch from this file. Change the CSS breakpoint to 960 for a redesign, forget this line exists, and the chart swap now happens at the wrong width for months.

Debouncing hides the symptom. It doesn't touch the actual defect, which is that resize fires on movement, and your logic only cares about state — whether you're above or below one line. Everything between crossings is wasted work.

What you actually want to watch

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. It's window.matchMedia(), and it returns a live object, not a one-off boolean:

const mobileQuery = window.matchMedia("(max-width: 899px)");

console.log(mobileQuery.matches); // true or false, right now

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

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 doesn't fire on every pixel of a drag. It 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.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The part resize was never going to give you

Here's the detail that makes this more than a tidiness upgrade: a media query isn't only about width. prefers-color-scheme, prefers-reduced-motion, prefers-contrast, hover, pointer — these are all valid media features, and none of them have anything to do with the size of the window.

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
  animationsEnabled = !event.matches;
});
Enter fullscreen mode Exit fullscreen mode

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 it. 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. matchMedia is the only way to know the setting changed while they were already looking at your site.

Try the difference, live

Talk is one thing; watching the event counts diverge in your own browser is another. The playground above 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, and a box where you can type any query of your own and watch it live.

The one gotcha worth knowing

MediaQueryList objects used to only support addListener() / removeListener() — an older, non-standard pair of methods that predate MediaQueryList implementing the standard EventTarget interface. They're deprecated now, but you'll still see them in code from a few years back. Use addEventListener("change", …) / removeEventListener("change", …) going forward — same object, the standard event methods every other DOM node already gives you, nothing extra to learn.

And remember to clean up: 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.

The lesson underneath the API

The pattern here isn't specific to breakpoints. It's a habit worth checking for anywhere: whenever you catch yourself polling a raw value on every tick of some 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. What's the last place in your own code you debounced something the platform could have told you about for free?

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)