I Deleted One Library and Cut Mobile Blocking Time by 500ms
Part of Building This Site and Small Business Websites
By Paul Peery · July 26, 2026 · 4 min readUpdated August 12, 2026

Every fade-in and slide-up on this site used to run through a JavaScript animation library. It looked great and it was easy to write — and it was the single heaviest thing my visitors' phones had to chew through before the page became usable. Mobile Total Blocking Time: 670ms. After removing the library and rebuilding every effect with CSS plus a tiny scroll observer: 170ms. Same animations, to the pixel, on a page that responds half a second sooner on a mid-range phone.
This is the write-up I wish I'd read first: why the library costs what it costs, the exact replacement technique, and the mistake I made along the way that briefly made things worse.
Why an animation library costs so much
Total Blocking Time measures how long the main thread is busy with chunks of JavaScript big enough that the page can't respond to a tap. An animation runtime hits it three ways: it's real kilobytes to download, real work to parse and execute, and — because animations are the first thing you see — it all runs during the exact window TBT measures. You're paying peak-price compute for decoration.
The unintuitive part: CSS can already do almost all of it. Transforms, opacity, keyframes, staggered delays, springy easing curves — the browser animates these off the main thread, in compositor code that shipped with the browser and costs your bundle zero bytes. What CSS can't do alone is react to scroll position — "animate this in when it enters the viewport." That's the one legitimate job the library was doing, and it's replaceable with about twenty lines.
The technique
Three pieces:
1. CSS owns the motion. Two classes: a hidden state (opacity: 0; transform: translateY(12px)) and a transition to the visible state. Every fade, slide, and stagger on this site is some variation of this, plus @keyframes for the ambient stuff (floating background shapes, hover lifts) that never needed JavaScript at all.
2. A 20-line IntersectionObserver replaces the scroll machinery: observe the marked elements, and when one enters the viewport, add the class that triggers its transition, then stop observing it. One observer, shared, no scroll listeners, no per-frame JavaScript.
3. The part that actually matters — render everything visible first. This is where my first attempt went wrong, and where most reveal-on-scroll implementations quietly damage themselves.
The naive version renders elements hidden (server sends opacity: 0) and JavaScript reveals them. Feels obviously correct — and it means nothing is visible until your JavaScript loads. On a slow phone that's your content, your headline, your Largest Contentful Paint, all hostage to hydration. When I first shipped the naive version, mobile LCP blew past four seconds — I had traded a blocking-time win for a paint-time disaster.
The fix inverts the default: the server renders everything visible. After the page mounts, a small script hides only the elements still below the viewport — the ones the visitor genuinely hasn't seen — and the observer reveals them on approach. Above-the-fold content paints instantly with zero animation dependency; below-the-fold content still gets its entrance. A no-JavaScript visitor simply sees everything, which is the correct fallback. Anything that must move from first paint (the hero) uses a pure-CSS entrance animation instead — no observer involved.
The other habits that rode along: the reading-progress bar is one requestAnimationFrame scroll handler; hover effects are pure CSS; and loading skeletons reserve full viewport height so streamed-in content doesn't shove the footer around — that plus reserved image space is why this site's layout shift is essentially zero.
Making the win permanent
Here's the part I'd argue is worth more than the optimization itself. A performance win that lives in your memory reverses the first time anyone — including future-you — runs npm install on something shiny. Mine is enforced twice, in CI:
- A unit test fails the build if an animation runtime — or any of a blocklist of heavy client libraries (charting kits, lodash, moment, three.js) — ever appears in the dependency list. The pull request that adds one goes red with a message explaining why.
- A byte budget per route. A script measures the gzipped first-load JavaScript of every significant page — including the money pages like services and contact — against a recorded baseline, and fails past ten percent growth. Growth is allowed, but only deliberately: you re-record the baseline in the same commit and justify it.
The rule I took away: an optimization isn't done when the number improves; it's done when a test fails if the number regresses.
Steal this
- Inventory what your animation library actually does. Most of it is transforms and opacity — CSS territory.
- Build the two-class reveal pattern + one shared IntersectionObserver.
- Server-render everything visible; hide below-viewport elements only after mount. Never gate first paint on hydration.
- Delete the library. Measure before and after on a real mid-range phone profile, not your laptop.
- Add the dependency-blocklist test the same day, while the motivation is fresh.
More of the engineering behind this site lives under Building this site — and if you want a site built lean like this from day one, here's how we'd work together.
Keep reading
All posts
Server Components vs. Client Components: A Practical Solo Dev Rulebook
Most Next.js tutorials make Server Components look like an all-or-nothing rewrite. Here is the exact mental model I use to decide where 'use client' belongs.
August 15, 2026 · 4 min read