Making Mobile Safari Fast: Two Bugs, One Symptom
The report was four words long: takes forever, sometimes never loads. It turned out to be two unrelated bugs wearing one complaint — one in how pages were delivered, one in what they did to the phone after they arrived. Here is the whole campaign, including the decision we reversed twenty-three days after making it.
On this page
Every performance report from a real person arrives compressed. Ours said the site took forever on an iPhone and sometimes never loaded at all. That sentence contains no diagnosis, and the fastest way to waste a week is to treat it as one bug — to start deleting animations because the page feels heavy, or to start adding cache headers because the page feels slow. Mobile Safari performance work only converges when you split the symptom in two: how long the phone waits for bytes, and what the phone does once it has them.
We measured both, found a separate root cause in each, and fixed them as two commits on the same day. The delivery half was a structural bug in our Next.js route tree that made every localized page dynamically rendered. The runtime half was accumulation — roughly ninety infinite animations, a WebGL stage arming on hardware that should never have been asked, and framer-motion's animation core riding in the home route's initial bundle.
- 250–950 ms → 3–8 ms
- home TTFB
- 1.12 MB → 468 KB
- home HTML
- 7 → 345
- prerendered routes
- −91 KB
- home initial JS
The two-front result, measured 2026-07-26 across the delivery and runtime commits.
Every locale route was dynamic
The delivery number was ugly before the network even entered the picture. Home TTFB ran 250–950 ms on localhost, with ten to twenty uncached Supabase queries per view. On a laptop, on loopback, with the database warm. Whatever the phone was experiencing, it was that plus everything a cellular round trip adds.
The cause was not the queries. It was that no page was allowed to be static. next-intl had never been opted into static rendering, and — the part that actually mattered — our root layout resolved the active locale from request state by calling getLocale(). A request-time read in a layout taints every route beneath it. Next.js correctly concluded that nothing under that layout could be prerendered, so every locale route was rendered per request, served no-store, and never became CDN-cacheable. The CMS fan-out then ran on every single view because there was nothing to cache it into.
The fix was not a cache header. It was deleting app/layout.tsx.
Next's newer next/root-params API exists for exactly this shape of problem, and it does not apply here: [locale] is not a root param while a layout sits above it. The only way to make the locale segment the top of the tree is to have nothing above it — which means giving up the single shared root layout entirely. So we did. There is now no app/layout.tsx in the repository. In its place are four root layouts, one per route family, each rendering a shared <RootShell> that owns the <html> and <body> markup and everything global: [locale], plus (admin), (admin-auth) and pitch, the last three hardcoding locale="en" because nothing in an admin console needs a translation table. global-error.tsx keeps its own <html>, as it must.
The other half of the opt-in is per-page: setRequestLocale has to be called in every server component that renders a localized page, and we now call it in 34 files. This is a genuinely bad invariant — it is invisible, it is silent when violated, and a page that forgets it does not break, it just quietly goes dynamic again and takes its TTFB with it. We wrote the rule into the file comments where a new page is most likely to be copied from, and we are writing it here too: new pages must repeat setRequestLocale.
One route stayed dynamic on purpose. The interactive roadmap reads useSearchParams without a Suspense boundary, which pins it to dynamic rendering; it is a noindex surface anyway, and forcing the issue would have meant restructuring a page nobody was complaining about. Pinning it explicitly beat leaving it accidental.
Caching the database, not the page
Prerendering fixes the request path, but our content is editable: section copy, page metadata and row content all come out of Supabase through a small CMS layer. A six-locale build matrix that hits the database once per query per page would trade a slow request for a slow build. So the three read paths went behind unstable_cache — getSectionRow under ["cms-section"], getRowsForLocale under ["cms-rows"], getPageMetaRow under ["cms-page-meta"], all with { revalidate: 300, tags: ["cms-content"] }. A page render, and the entire six-locale build, now hits Supabase once per distinct query per five minutes instead of once per request.
One detail in that sentence is load-bearing and easy to lose in a refactor: the CMS uses a cookie-free public Supabase client. Calling cookies() inside unstable_cache throws in Next 15+, so a client that reads the session cannot be cached at all. We know this because the same mistake had already silently killed our database-backed translation overrides months earlier, behind a bare catch. Editors do not wait out the five minutes either — admin write actions pair revalidatePath with revalidateTag("cms-content"), and the i18n overrides carry their own "i18n-overrides" tag.
The delivery numbers moved as a block. The build went from 7 prerendered routes to 345, across 497 static pages. The home page serves with x-nextjs-cache: HIT and TTFB in the 3–8 ms range — CDN numbers, because it is now a CDN artifact. The HTML itself fell from 1.12 MB to 468 KB, though not entirely for reasons of prerendering, which brings us to the decision we reversed.
The same lever, twenty-three days apart
On July 3 we turned Next's inlineCss on. The reasoning was sound and the evidence was real: our stylesheet was 38.6 KB, it was the last render-blocking request on the page, and it was costing roughly 530 ms of mobile FCP. Inlining it removed a round trip from the critical path. We tightened browserslist in the same change — Chrome, Edge and Firefox 111+, Safari and iOS 16.4+ — because a modern baseline is what makes a small stylesheet small.
On July 26 we turned it off. Two things had changed. The stylesheet had grown to about 320 KB raw (roughly 40 KB gzipped); and inlined CSS rides along inside every navigation's HTML, with no way for the browser to cache it between pages. When every page was a dynamic render, that was a fair trade — the HTML was uncacheable anyway, so the CSS lost nothing by joining it. Once routes were prerendered and CDN-cached, the arithmetic inverted: one cacheable stylesheet, fetched once, beats 320 KB re-delivered inside every document.
The same lever, the opposite conclusion, twenty-three days apart — and both calls were correct in their context.
We keep this pair in mind whenever someone quotes a performance rule at us. inlineCss is not good or bad. It is a bet on the ratio between your stylesheet size and your HTML's cacheability, and we changed that ratio ourselves.
Ninety animations nobody was watching
The runtime half was not one mistake; it was three months of small ones. Infinite CSS animations, page-wide blend layers, a WebGL stage, and framer-motion's eager core in the initial bundle — each defensible when it landed, collectively a phone-melter. The clearest measurement: by the time you scroll to the home page's closing CTA, roughly ninety infinite animations are live simultaneously. The compositor does not care that eighty of them are far offscreen.
The gate is deliberately dumb. SectionViewportGate is a client component that observes every #main-content > section with a 160 px root margin and toggles a data-offscreen attribute; CSS does the rest with animation-play-state. The attribute flips happen outside React on purpose — routing ninety observer callbacks through state updates would have cost more than the animations we were trying to pause.
Alongside it we reached for content-visibility: auto, which lets the browser skip layout and paint for sections it has not scrolled to. That change shipped with a bug worth the retelling.
/* nth-of-type, not nth-child. Three JSON-LD <script> children
precede the sections inside <main>. */
section:nth-of-type(n + 3) {
content-visibility: auto;
contain-intrinsic-size: auto 800px;
}One pseudo-class, one class of very confusing CLS. The hero must never match this rule.
Written as nth-child(n+3), the selector counts all children of <main> — and three JSON-LD <script> tags come first. The offset shifted by three and the rule landed on every section, hero included. content-visibility implies contain: size, so the 100svh hero laid out at its 800 px placeholder height first and corrected itself a moment later: a load-time reflow and a layout shift on the very first screen, produced by a change whose entire purpose was to make the first screen cheaper. nth-of-type counts only <section> elements. One word, and the hero is out of scope again.
Every iPhone is a low-tier device
Our WebGL surfaces sit behind a quality tier detector. detectTier reads four signals — pointer: coarse, hardwareConcurrency ?? 4, deviceMemory ?? 4, and the smaller screen dimension. A device is a phone if it is coarse-pointered with a minimum dimension under 700 px, and a phone is only promoted to medium with at least six cores and at least six gigabytes of reported memory.
| Tier | How a device gets there | DPR cap |
|---|---|---|
| low | Any phone that fails the 6-core / 6 GB promotion — which is every iPhone | 1.25 |
| medium | Desktops by default, promoted phones, and every server render | 1.5 |
| high | Fine pointer with the cores and memory to back it | 2 |
The tier table, and the reason the SSR default is medium: the server cannot know, and medium is the honest guess.
Safari exposes no deviceMemory at all. The ?? 4 default therefore fails the ≥6 gate unconditionally, and every iPhone lands in the low tier. We spent an hour treating that as a detection bug before concluding it was the right answer arrived at by accident: iOS Safari has the lowest WebGL context cap of any browser we support and is the most willing to kill a tab under memory pressure. So the stage now returns before mounting when tier === "low", which means the stage chunk is never even fetched. The cheapest WebGL context is the one you decline to create.
The rest of the runtime diet is a list of small amputations, each removing work that touch devices could not benefit from in the first place:
- The hero's code field renders static on touch with no frame loop at all. There is no cursor to chase, and the roaming lens was costing a full-viewport canvas redraw at 60 fps on the phone's first screen. Its resize path also skips the ~2,600-call
fillTextrebuild, because on iOS the URL bar collapsing counts as a resize. - The code-lens overlay is gated to
pointer: fine. It is a fixedmix-blend-modecanvas — it taxed every scroll frame on phones to produce an effect touch cannot trigger. - The featured-work orbs traded
filter: blur(28px)for baked three-stop radial gradients. They animate their scale, so the gaussian was re-running every frame; a gradient that looks the same costs nothing to re-scale.will-changeis now scoped to hover-capable pointers, and touch flattenspreserve-3d. - The videos were re-encoded to 960×540, taking them from 11.2 MB to 5.1 MB. They were renamed to
*-2.mp4to honor our immutable-cache filename contract — the assets shipmax-age=31536000, immutable, so a changed file needs a changed name, and in exchange repeat visits skip roughly 100 MB.
LazyMotion, on the second attempt
The longest-running item in the campaign started with a bundle report blaming 63 KB of unused JavaScript. We went looking and found the chunk was react-dom itself, which is not something you trim. The actual fat was framer-motion: importing motion anywhere pulls in the animation core eagerly, and our home page imported it in most of its sections.
The July 3 attempt wrapped each section in its own LazyMotion with strict enabled, taking home transfer from 284 KB to 269 KB. Strict mode is what makes the saving real — it forbids the plain motion.* components, so a single stray import cannot quietly drag the core back in. It also has teeth: we had to kill framer's imperative animate() inside our counting-number hook and rewrite it as a small requestAnimationFrame easeOutQuart, because that one import was enough to put the animation core back on the LCP-critical graph. A loop variable named m in the gravitas-orbit component had to be renamed, because it shadowed framer's m namespace.
Then it was reverted. The revert is a clean, complete revert with an auto-generated commit body and no stated reason, which means the most honest thing we can say about it is that we do not know what broke. We have opinions; we do not have evidence. That gap is its own lesson about commit hygiene, and it is the reason the re-land took a different shape.
// One shared provider. The feature bundle is loaded asynchronously,
// so it streams in after first paint instead of blocking it.
const loadDomAnimation = () =>
import("framer-motion").then((m) => m.domAnimation);
<LazyMotion features={loadDomAnimation} strict>The re-landed form: a single MotionProvider with an async feature loader, and strict mode keeping every consumer honest.
The July 26 version replaced per-section wrappers with one shared MotionProvider, and it went in only after every consumer on the home route had been audited by hand. There are three: the games showcase, the Lab rail, and the card-tilt wrapper. The house rule is now written down — home sections use m.* under the provider, never motion.* — and strict mode enforces it at runtime rather than at code-review time.
One trap is worth naming because it is invisible in review. CardTilt keeps a constant element type: it gates its behavior at the style level and never swaps a div for an m.div. Swapping the element type on the client would remount the whole subtree after hydration — which, for a card that contains a canvas stage, means tearing down and rebuilding the thing you were trying to make cheaper. Gating with styles is uglier and correct.
Home initial JavaScript ended at 1,365 KB raw, down from 1,456 KB, with zero chunks carrying the motion core.
The last ulp
The strangest artifact of the campaign had nothing to do with speed. One of our game posters — riftline's — computes its SVG coordinates from trigonometry, and React 19 checks server-rendered attributes against client-rendered ones during hydration. Node's libm and Chromium's libm disagree in the last unit in the last place. The two strings differ in a digit no human will ever see, React notices, and you get a hydration warning on a static picture. The fix is to quantize the coordinates before they become attributes, which is another way of saying: if two implementations must agree on a number, do not ask them to agree on all of it.
Both commits shipped with tsc, eslint and the production build green, and all 27 pre-existing end-to-end tests passing. That last check matters more than it sounds: nearly every change here removes work, and removing work is the easiest way to remove a feature by accident.
What we would tell someone starting the same campaign is that the split is the whole method. Delivery bugs and runtime bugs produce identical complaints and share almost no fixes; the delivery half here was one structural decision about where the locale lives in the route tree, and the runtime half was ninety small ones. Neither would have been found by staring at the other.
The invariant we now live with is the one we like least. setRequestLocale must appear in every localized page, and nothing fails when it does not — the page simply stops being static and the whole delivery win quietly leaks away one route at a time. If you want to see the shape of the site those numbers pay for, the Lab is the heaviest thing we serve and the best test of whether the diet held. The rebuild of the front page explains why there is so much to render in the first place, and six languages in one codebase explains why the locale segment was sitting where it was.