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));
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);
});
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;
});
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:_
* ⭐ **GitHub** — follow me and star the projects: github.com/parsajiravand
* 💬 **Discord** — join the frontend best-practices community: discord.gg/d9KRhuAwQ
* 📸 **Instagram** — frontend best practices, daily: @bestpractice___