The Browser Already Wrote That Code. Delete Yours.

You have probably shipped a resize handler that fires more than sixty times a second to answer a question the browser already answers exactly once. The tell is usually visual: a sidebar that flickers between two states while someone drags a window across a breakpoint, or a chart that swaps to its mobile version, swaps back, and then settles somewhere you did not intend.
The reflex is to debounce — wrap the handler so it waits until the input stops. That helps, and it is also the wrong fix. Debouncing changes when you ask the question. It does not change the question, and the question was never about resizing. Nobody cares that the window moved. You care whether the viewport is currently above or below one line. That is a boolean, not a stream of pixel events.
The real bug is a missing primitive, not a missing delay
Think about what the code is actually doing. You subscribe to every resize event on the page, compare window.innerWidth against a number like 900, and then do something heavy when the answer changes. Three problems hide in that one comparison.
First, resize fires on movement. Mid-drag that can be more than sixty events a second, and nearly every one of them computes the same answer you already had. Second, the number 900 does not live in this file. It also lives in your stylesheet, and the day someone changes the CSS breakpoint during a redesign, the JavaScript keeps swapping the chart at the old width for another six months. Third, the comparison is fragile right at the boundary: a vertical scrollbar appearing or disappearing shifts window.innerWidth by roughly 15 pixels, which is enough to flip your check without anyone resizing anything on purpose. A CSS breakpoint evaluated by the browser's own layout engine does not have that problem, because the layout engine is the same code that lays out the page.
What the browser has shipped for years
window.matchMedia takes a media query string — the exact string you would write in CSS — and returns a live object with a matches property and a change event. That event fires once, at the instant the query's truth value flips from false to true or back. Drag across the boundary ten times and you get ten events, not six hundred. That is not a micro-optimization; it is a different cost class, and it is the difference between a chart that swaps once and a chart that visibly stutters.
Two details are worth getting right. First, copy the query string out of your stylesheet instead of retyping the number, so the breakpoint exists in exactly one place. Second, use addEventListener('change', ...) and remove it on cleanup. The older addListener and removeListener pair still works in most browsers but is deprecated; if you create a listener inside a component that unmounts, forgetting cleanup leaks a callback that keeps firing for a component nobody can see.
MDN's matchMedia reference covers the whole API surface in a couple of screens. It is worth five minutes before your next frontend ticket.
The resize handler is usually a symptom, not a disease
The habit underneath is bigger than one listener: re-implementing a browser primitive in JavaScript because you can already see exactly how to write it. Four common versions of the pattern, roughly in the order you will find them in a mature codebase.
- Scroll listeners that only care about a crossing. If your handler asks 'has this section scrolled into view?' or 'should I load more?', you are describing an intersection, and IntersectionObserver reports exactly that as elements cross a threshold — without you computing positions on every scroll frame. Scroll fires constantly; crossings are rare events.
- Viewport width when the question is element width. A card inside a resizable panel is not the same width as the window. Container queries let a component respond to its own container, and ResizeObserver gives you the element's box. Trade-off: neither is a drop-in for matchMedia when the thing you are measuring really is the window, and container query support is newer by a wide margin, so check your browser floor before you refactor.
- Hand-rolled dialogs and focus traps. Focus management — moving focus in, keeping Tab inside, restoring focus on close — is one of the genuinely hard accessibility problems, and a native dialog element opened modally handles it, plus Escape and the backdrop, in a few lines. Popover and the :has() selector remove similar piles of state-tracking code. Trade-off: styling native dialogs across browsers still needs care, and older Safari versions will push you back to a polyfill.
- requestAnimationFrame math for what is now a CSS animation. Scroll-driven animations can tie an effect to scroll position with no per-frame JavaScript callback at all. Support is the honest caveat here — this is the one item on the list I would check against real traffic data before adopting broadly.
The case resize can never cover
Here is the argument that actually wins the code review. Some of the most important media features have nothing to do with window size. prefers-reduced-motion, prefers-color-scheme, and prefers-contrast describe the person, not the viewport. There is no resize-shaped event for 'the user just turned on reduced motion while your tab was open.' There is no window dimension that encodes it.
That matters because of where the gap sits. CSS can honor prefers-reduced-motion for transitions and animations it drives itself, but it cannot reach an animation timed with requestAnimationFrame — which is how most custom scroll effects, animated counters, and entrance sequences are built. If that code reads the setting once at mount and never again, you have built an accessibility feature that works only for people who happened to have the setting on before your page loaded. The people who need it most are the ones who turn it on because something on your site made them feel unwell. A live matchMedia listener is the only way to react to that change, and it is also the only way to test it without a reload.
Where you should still write it yourself
Be honest about the trade-offs, because the platform does not win every time. Keep the listener when:
- You need the number, not the state. Drag handles, canvas layout, virtualized lists, and anything doing geometry need a value per frame. Observers tell you that something changed; they do not hand you the arithmetic.
- Your support floor is older than the primitive. IntersectionObserver, container queries, :has(), and scroll-driven animations all landed at different times. If you serve enterprise browsers or embedded webviews, verify against your own analytics rather than a support table you skimmed.
- Your test environment is a stub. jsdom does not implement matchMedia, so you will need to mock it, and mocks can lie to you. A resize listener is trivially fakeable. Choose the primitive whose failure mode you can actually test — and then test it.
When you do keep a listener, keep exactly one. A single shared hook that owns the raw value and hands it to everyone else is debuggable. Seven components each reading window.innerWidth at their own pace are not.
A 30-minute audit you can run today
- Search the codebase for addEventListener('resize', 'scroll', and 'orientationchange', plus every direct read of innerWidth and innerHeight. Each hit is a candidate, not a conviction.
- For each hit, write the question being asked in one sentence: 'Is the viewport narrower than the sidebar breakpoint?' 'Has this section entered the viewport?' 'Does the user prefer reduced motion?'
- If the answer has a handful of states, replace the listener with the primitive that emits on transitions. If it needs a continuous value, leave the listener but move it behind one shared hook.
- Delete any matchMedia addListener calls in favor of addEventListener, and confirm every matchMedia listener is removed on unmount.
- Add a live check for prefers-reduced-motion anywhere JavaScript times an animation.
- Measure the result in your browser's performance panel. Record a drag across the breakpoint before and after, and compare how many handler invocations you removed. 'I deleted 40 lines and 600 event calls per drag' is a review argument that survives contact with a skeptical teammate.
The reason this is worth an afternoon is not that fewer listeners is tidier. It is that every re-implementation of a browser primitive is a small bet that you understand the edge cases better than the people who wrote the layout engine. Sometimes you do. Usually you do not know the edge cases exist until a scrollbar shows up.
Key Takeaways
- Debouncing a resize handler delays a question you should not be asking. If you only need to know which side of a line the viewport is on, matchMedia answers it once per crossing instead of sixty times a second.
- The magic number is the real bug: a breakpoint typed into both CSS and JavaScript drifts, while a single query string cannot.
- The same pattern shows up with scroll listeners (IntersectionObserver), viewport checks for element size (container queries and ResizeObserver), and hand-built dialogs (the native dialog element).
- No resize event can tell you that a user just enabled reduced motion. If your animations are timed in JavaScript, one matchMedia listener is the difference between an accessibility feature and a decoration.
- Keep writing listeners when you genuinely need continuous numbers or your browser floor demands it — but own the listener in one place, and validate the decision against real traffic instead of a support table.