Fast to Load, Slow to Scroll: What Profiling Our Own Site on a Phone Found
Last week I opened our own portfolio page on my phone, by scanning a QR code we had printed for an event. It took a long time to show anything, and when I scrolled it stuck. The home page, on the same phone, was fast. Every tool we own said the portfolio page was fine: the HTML was 74 KB compressed, and in emulation its first paint came earlier than the home page's. That gap — a page that measures fast and feels slow — is common, and it hides behind a specific class of mistakes that lab tools are not built to see. We spent the week finding them on our own site. This is what we found, in the order we found it, with the numbers.
Key takeaways
- A page can be fast to load and slow to scroll; the second problem lives in rendering, not bytes, and lab scores do not see it.
- content-visibility skips rendering per element — one box for a long list renders the whole list at once; give each item its own, sized by height only.
- A moving blur is re-composited at screen size every frame; keep glows static and allow one deliberate ambient loop per site.
- Backdrop-filter is cheap on panels that scroll with the page and expensive on anything sticky, fixed, or over a playing video.
- Count route prefetches on a fresh visit before any interaction; every one should be a page the visitor is likely to open.
- Measure before you promise: the stylesheet split we expected to halve the CSS trimmed it by 15 %, which is what we published.
The stall: one rendering box for eighteen projects
The portfolio lists sixteen client projects, each a full-width browser frame with a screenshot, a story column, and a handful of small scroll-linked animations. Modern browsers can skip rendering content that is far off screen — the CSS property is called content-visibility — and the page used it. But it used it on the whole list at once: one box, 15,600 pixels tall. Skipping is per element. The moment the top of that box came near the phone's screen, the browser laid out, painted, decoded and started animating all sixteen frames together.
You cannot see that in a load test, because it happens after the page has 'loaded'. You can see it with one line in the browser console: document.getAnimations().length. At the top of the live page, before anyone scrolled, 139 animations were running. On the home page, whose chapters are each a separate box under 2,600 pixels, the same count was 39. That single comparison was the diagnosis.
The fix was to make every row its own box, with an estimated height so the scrollbar stays honest and a little padding so the box does not clip the frame as it drifts. After the change the live page renders none of the sixteen rows at the top and two to five at a time while scrolling, each image decoding as its row arrives — exactly the home page's behaviour. The animation count at the top went from 139 to 15.
The one-value shorthand sets the width too
Our first fix set the row's estimated size with a single value, which also gave every row an intrinsic width of 900 pixels — and on a 390-pixel phone the grid track obeyed it. The rows rendered wider than the screen. Estimate the height only; a grid or flex item must never be given an intrinsic width.
Blur that never stops
The second cost was decorative. Most of our pages had a soft teal glow behind the hero: a large disc with a 130-pixel blur, and an eleven-second animation that made it slowly breathe. A blur that stands still is rasterised once. A blur that moves is re-composited at full screen size on every frame it is on screen, which on a phone means the graphics processor is busy before the page has finished loading. On the portfolio page, four small stat tiles sat on top of that moving glow with a backdrop-filter of their own — so the tiles re-blurred the moving backdrop every frame as well.
Both idioms came out. Glows on every page are static now; the tiles use a plain translucent fill that looks the same. The one exception is the home page's hero, whose three slow blooms are the site's single sanctioned ambient animation. We then counted the same way across the whole site: a loading shimmer that kept sliding under images long after they had loaded — eighteen of them still running on the home page — and a pulsing green dot in every browser-frame address bar, thirty-four of them. Both now run a few times and stop. The audit line is getAnimations().filter(a => a.effect.getTiming().iterations === Infinity), and the answer on every page but the home is now zero.
Backdrop filters on things that scroll
Backdrop-filter — the frosted-glass look — is cheap on a panel that scrolls with the page, because the content behind it does not change. It is expensive on anything sticky or fixed, because the content behind it changes on every scroll frame and the blur is recomputed each time. We had it on the project pages' sticky action bar, on the tools hub's sticky category strip, on the cookie banner, and on the mute button floating over a playing video, where it was recomputed on every video frame. Every one of those is a near-opaque plain fill now. The tool pages' 176 frosted panels, which scroll with the page, cost nothing per frame and were flattened anyway for consistency.
The way to find these is to scroll first and then count, because a navigation bar often becomes translucent only once the page has moved: [...document.querySelectorAll('*')].filter(e => getComputedStyle(e).backdropFilter !== 'none'). On our site the only element left in that list is the cookie banner while it is open.
The bytes you did not ask for
Modern frameworks prefetch the pages a visitor might open next, which is a good default for a primary call to action and a bad one for a link nobody taps. Tracing the requests on a fresh visit showed the cookie banner's small 'Learn more' link fetching the cookie policy page — four requests, 35 KB — on the first visit of every page of the site. The 'Back to home' link on every legal page fetched the entire home page, 66 KB, before the visitor had done anything. Someone landing on a privacy policy from an app store listing was downloading half the home page in the background.
Those links now prefetch only when a finger or a pointer actually reaches for them, which is what the navigation bar's logo already did. The audit is to count requests carrying the framework's prefetch marker before any interaction; on our site that count is now zero on every page except the home, which keeps one deliberate prefetch of the contact page.
| Page | Before | After |
|---|---|---|
| Any page — cookie banner's 'Learn more' | 35 KB (4 requests) | 0 |
| Any legal page — 'Back to home' | 66 KB (4 requests) | 0 |
| Home — 'Start a project' | 38 KB | 38 KB, kept on purpose |
Speculative bytes on a first visit, before and after
The banner and the stylesheet
Two smaller things rounded the week out. The cookie banner covered the lower 40 % of a phone screen until answered — an icon tile, four sentences and a second row of buttons — over the first content of every page. It is one short paragraph and two side-by-side buttons now, 21 % of the screen, and its Decline button has a label instead of a bare ✕ that read as 'close'.
And the stylesheet. We inline CSS into the HTML so that nothing render-blocking stands between the visitor and the first paint, which means every byte of the stylesheet ships inside every page — and, in a framework that streams components, inside the page's data payload and every prefetch as well. One global sheet for 155 routes sent the 129 tools' utilities to the marketing pages and the marketing pages' utilities to the tools. Splitting it into a base plus one sheet per route group cut each document by about 4 KB compressed. That is honest and small: I had estimated it would halve the stylesheet, and measurement said 15 %, because the two halves of the site share most of their vocabulary. Measure before you promise.
A page that measures fast and feels slow is hiding a rendering cost, not a network one — and rendering costs show up after the load test has stopped looking.
What to check on your own site
None of this needs a lab. It needs a phone-sized viewport, the browser's console, and the habit of looking after the page has loaded rather than before. The checks below took us from a page that stalled to one that scrolls like the home page, and each one is a single line.
- Count document.getAnimations() at the top of the page. If it is far above the number of things moving on screen, something below is being rendered that should not be.
- Filter those animations for infinite iterations. Anything that never stops keeps the compositor busy forever; keep one deliberate loop at most.
- After scrolling a bit, list elements whose computed backdropFilter is not 'none'. A sticky or fixed one is recomputed every frame.
- Throttle the CPU six times in devtools and scroll the whole page slowly; a long task during that scroll is a stall a phone will feel.
- Watch the network on a fresh visit for prefetch requests before you touch anything; each one is bytes a visitor may never need.
- Give long lists a rendering box per item, sized by height only, never one box for the whole list.
Why we publish this
Every number above was measured on nexinfinitymeta.ai, and every fix shipped the same day it was measured, behind the same gates we use for client work: a scripted motion harness, a per-page stylesheet coverage check, a byte budget per route, and a parity check across all 219 indexed pages. The earlier post on why a website feels slow on mobile covers the loading half of the problem. This is the scrolling half, and it is where most 'fast' pages are still slow.
Frequently asked questions
The page scores green in Lighthouse. Why does it still stall on my phone?
Lighthouse measures loading and the first seconds of interactivity. A stall while scrolling comes from rendering work that starts later — a long list rendered all at once, animations that never stop, blur recomputed every frame. Count the running animations and blur-behind elements in the console after the page has loaded; that is where it shows.
Is content-visibility safe to use?
Yes, if it is applied per item and the estimated size is a height only. Applied to a whole list it defers nothing useful — the whole list renders the moment its top comes near. Applied with a single-value size it also sets an intrinsic width, which broke our rows on phones until we changed it.
Should we remove every animation?
No. Keep motion that has an end — entrances, scroll-linked effects that settle, a shimmer that stops once its image lands — and remove motion that runs forever, especially on blurred or large layers. One deliberate ambient loop on a hero is fine; thirty-four pulsing dots are not.
Does prefetching hurt?
It helps for the one or two pages a visitor is likely to open next and hurts everywhere else, because it spends the visitor's data on pages they never see. Keep viewport prefetch on the primary call to action and switch every chrome link — footer, legal, banner — to prefetch on hover or touch.
Have a project in mind?
We design, build, and ship software end-to-end — with a fixed, written quote after a free scoping call.
