Responsive design taught everyone to hide things on small screens. A decorative panel gets display: none below some breakpoint and the layout is fixed. The part that doesn't get said is that the phone still downloaded, parsed and ran everything that panel needed. CSS hid the pixels. It didn't hide the JavaScript.
On this site that was worth measuring. Two libraries existed purely for effects a phone never sees, and gating them behind the same media query the CSS already used cut the mobile payload by roughly two thirds. Here are the actual numbers and the pattern.
The two offenders
three.js, for an animated hero canvas. In the current build that's 734 KB raw, 189 KB gzipped. The canvas is decorative, it's genuinely nice on a large screen, and on a phone it is not shown at all.
Lenis, for smooth scrolling. Smaller, but worse conceptually: mobile browsers already have excellent native momentum scrolling, tuned per platform. A JavaScript smooth-scroll library on a touch device is not an enhancement — it intercepts a scroll that already felt right and hands it to a rAF loop that competes for the main thread. It makes the experience worse and charges you bytes for the privilege.
For comparison, the entire rest of the application — React, every component, all the page logic — is 85 KB gzipped. The decorative canvas library alone was more than twice the size of the actual site.
The pattern
It's the combination of two things, and it only works if you use both: a dynamic import() so the bundler splits the library into a chunk that isn't requested by default, and a matchMedia check so the import only happens when it's warranted.
The static import is what defeats people. If the module is imported at the top of a file, the bundler must include it in that chunk, and the browser fetches it before it can run any of your code — the runtime check happens far too late to matter. The check has to gate the import itself, which means the import has to be dynamic.
Match the breakpoint to the one your CSS already uses. If the canvas is hidden below 48rem, that's the same number that should decide whether three.js loads. Two breakpoints that mean the same thing will drift, and the failure — a device that downloads the library and then hides the element — is silent.
Check reduced motion in the same place
Both of these are animation. Someone who has asked their operating system for reduced motion should not get a rotating 3D canvas or a hijacked scroll, and once you're gating the import behind one media query, the second one costs nothing.
This is a strictly better outcome than the usual approach of loading the library and disabling the effect. The user who asked for less motion also gets less JavaScript, which is a benefit they didn't ask for and would probably like.
The part people get wrong: it can change after load
A media query is not a one-time answer. A window gets resized, a tablet rotates, a phone browser is opened side by side with another app. If you check once at mount, a user who rotates into a wide layout gets the hidden-desktop-only version of the page with nothing where the effect should be.
So subscribe to the query with a change listener rather than reading .matches once — and load the library on transition into the wide state, not before. Two details make this well-behaved: guard against loading twice if the user resizes back and forth, and handle the case where the component unmounts while the dynamic import is still in flight, so you don't initialise a canvas into an element that's gone.
Don't forget the fallback
If a decorative canvas is the entire visual interest of a section, the phone version can't be an empty box. The gated code path needs a static alternative — a gradient, an SVG, a still image — so the layout still reads as designed.
Which is a good discipline anyway: it forces you to design the version without the effect, rather than treating it as a degraded accident. If the static version looks fine, that's also useful information about whether the effect earns its 189 KB on desktop.
The result
A phone loading the homepage now fetches the app chunk and the page entry — about 97 KB gzipped — and neither three.js nor the scroll library. A desktop visitor gets those on top, on a fast connection, for an effect they can actually see.
The wider point is that a bundle-size number is one number for a site that has several audiences. "Our JavaScript is 290 KB" was true and useless; the useful question was "what does a phone on a slow connection actually download", and the answer was full of things it had no use for.
Look at your build output and ask, for each of the largest chunks, whether every visitor needs it. Anything driving an effect that a media query already hides is a candidate, and it is usually the biggest thing in the list.