On a phone, parse and run time outweighs download size. Have the server drop the modules a route cannot render, swap React for preact/compat where hooks suffice, load animation async with a deadline, cache the HTML at the edge, then measure again, because the bottleneck moves.
There is a kind of slow that is hard to argue with because nothing looks wrong. The page has one photograph, a headline, a few cards. On a laptop it appears at once. On a mid-range phone on mobile data it sits on a blank screen for a beat too long, and when you open the network panel nothing in the list is obviously heavy. No 4 MB image, no video, no wall of tracking scripts. PageSpeed Insights calls it unused JavaScript, and the way to reduce unused JavaScript is to send each page only the code it can use.
On a JavaScript application, the cause is usually in what the files contain, not in the list. The page is carrying code it cannot use: components for other pages, a full framework runtime where a smaller one would do, an animation library that the first paint waits for. Every byte of it has to arrive, be parsed and be run before the phone shows anything, and a phone is far slower at the last two than the machine it was built on.
I found exactly this on Lampstand Edu Consult, an education consultancy that places Nigerian students at universities abroad. I built their public site and the CMS behind it. After launch I measured the front end properly, took four passes at it in a single day. The page went from about 480 KB to 250 KB. Nothing on screen changed. Measured again on 6 October 2026 with Google’s PageSpeed Insights on mobile (Lighthouse 13.5.0, an emulated Moto G Power on slow 4G), the homepage scores 95 for performance and paints its first content in 1.3 seconds.
A page can be light on screen and heavy in the parser
Download size is the number everyone looks at, and it is the least interesting one on a phone. A compressed script is small on the wire. Once it lands, the browser decompresses it, parses all of it, compiles it and runs whatever sits at the top level, and it does that on a processor that may be several times slower than a developer’s laptop. Until the scripts the page depends on have run, a client-rendered page has nothing to paint.
So the question I ask of a slow application is not “what is large?” but “what is this page running that it cannot show?”
The Lampstand front end is React written as plain JSX, compiled by Babel from a PHP build script, with no bundler. Each source file becomes one browser script and the HTML shell lists them in order. That made the problem easy to see once I looked: the list was the same on every page.
The homepage was running five pages it could never show
The public site has one module for the homepage and one that holds every other page: About, Services, Destinations, Success Stories, Contact and the legal pages. The shell loaded both, everywhere. A reader who opened the homepage waited while their phone downloaded, parsed and evaluated the code for five pages that the homepage has no way to display. The reverse was also true: someone landing on Contact from a search result paid for the homepage’s hero, counters and carousels.
That is how a single-page application starts life, and at launch it worked. It only shows up when you ask what each page pays for.
The server already knows the route, so let it choose the script
The usual answer to route splitting is a bundler with dynamic imports. For a site of this size that would have been a new build system to fix a problem that fits in a dozen lines, because the server already renders the shell for every request (it injects the per-route titles and descriptions so crawlers see real metadata). It knows which page it is serving. So it removes the script tag the page cannot use.
$routeModules = [
'/' => 'home',
'/about' => 'pages',
'/services' => 'pages',
'/destinations' => 'pages',
'/success-stories' => 'pages',
'/contact' => 'pages',
// ...
];
$needed = $routeModules[$path] ?? null;
$modules = ['home', 'pages'];
if ($needed !== null) {
foreach ($modules as $mod) {
if ($mod === $needed) continue;
// Drop the tag. The prefetch happens from boot.js after the load event
// rather than as a <link> here, so warming the next page cannot become
// part of how long this one takes to finish loading.
$html = str_replace('<script defer src="/assets/js/app/' . $mod . '.js"></script>', '', $html);
}
// ...
} Two details make this safe. The static shell still lists every module, so opening the raw HTML file directly still works; the server only ever subtracts. And the client carries a small table of which component lives in which module, so a navigation to a page whose code has not arrived yet fetches it first and only then animates away from the current page. On a slow connection the reader keeps looking at the page they were on, rather than at an empty frame.
On its own, the split was worth 21 KB. That is the smallest saving in the table below, and I would still do it first: until it lands, every other optimisation is applied to code the page will never use.
The crash that showed where the seam really was
The first run of the split broke the Contact page. The form’s Field component had been written inside the homepage module, and because both modules were always loaded together, nothing had ever noticed. The moment the homepage code stopped arriving on Contact, the form threw a ReferenceError.
Those errors are useful. A codebase that has always shipped as one piece has a dependency graph nobody drew, and splitting it draws it for you. Anything two pages share belongs in the module both receive. Split first, then follow the errors.
Warming the next page must not count against this one
Taking the other module off the page raises a fair objection: the first click to another page now has to download it. The fix is to fetch it early, while the reader is still reading. The obvious way is a prefetch hint in the head of the document.
I tried that, and it was measurably worse. A hint in the head competes for the connection while the page is still loading, so the cost of warming the next page landed inside the load time of the current one. What worked was waiting for the load event, pausing briefly, and only then adding the hint from script. The other module is usually in the cache before anyone clicks. Preparing the next page is a favour, and it should never be charged to the page the reader is on now.
A fifth of the runtime rendered the same public site
The largest single saving came from replacing the runtime rather than trimming my own code. The public site uses hooks and createElement and nothing more exotic. That is exactly what preact/compat implements, behind React’s own names. React and React-DOM together were 47 KB on the critical path of every first visit; Preact, its hooks and the compatibility layer are 11 KB. Thirty-six kilobytes, for a change the component code never saw.
There is no bundler to alias one package to another, so the swap happens in the browser: Preact loads first, then a small shim hands it React’s names before any application code runs. The one thing missing from the browser build of the compatibility layer is createRoot, which the shim writes itself.
(function (w) {
var compat = w.preactCompat;
if (!compat) return; // never leave the page without a React
if (typeof compat.createRoot !== 'function') {
compat.createRoot = function (container) {
return {
render: function (vnode) { compat.render(vnode, container); },
unmount: function () { compat.render(null, container); }
};
};
}
w.React = compat;
w.ReactDOM = compat;
})(window); I did not swap the admin. Its rich-text editor is the part most likely to depend on a renderer’s exact behaviour, and it sits behind a login where a heavier first load costs far less than a surprise mid-edit.
The swap broke a form that only a half-filled test could catch
Before calling it done I drove the running site rather than reasoning about it: every page rendered and navigated, the twelve fields of the contact form validated and submitted and reached the API, labels still paired with their inputs, accordions opened, the drawer opened, the theme switched, the counters counted.
A bug turned up the next day anyway. The contact form used uncontrolled dropdowns with a defaultValue, read at submit time. React applies defaultValue once, when the element mounts. The compatibility layer applies it again on every render. Ticking the consent checkbox changed state, the form re-rendered, and all seven dropdowns snapped back to their placeholders while the typed fields kept their text.
My verification had submitted the form many times and never once changed anything while it was half filled in, which is the only situation that exposes the difference. When you swap a runtime, test every form by changing something mid-way: tick a box, trigger a validation error, open an accordion. Then load the same page on the old runtime before writing a fix. It takes two minutes and tells you whether you caused the bug.
The fix was to make the fields controlled, not to special-case the checkbox. State that survives any render is correct under both runtimes, keeps the reader’s answers when a submission fails, and turns “start again” into a reset instead of a reload. An uncontrolled input is only borrowing the DOM’s memory, and a different renderer is entitled to return it.
An animation that arrives late is worse than one that never arrives
GSAP drives the entrance animations. It was loaded with defer, which sounds harmless but is not: deferred scripts run in document order, so 31 KB of animation library sat in the queue ahead of the application and delayed the moment anything appeared. The content does not need GSAP to be readable. So it moved to async, which takes it off that chain entirely.
That created a new obligation. An async script can arrive before the application or well after it, so every entrance has to cope with the library being late. The helper waits a short while and then gives up for good:
function whenGsap(fn, deadlineMs) {
if (typeof gsap !== 'undefined') { fn(gsap); return; }
const limit = (typeof deadlineMs === 'number' ? deadlineMs : 900) + Date.now();
// setTimeout rather than requestAnimationFrame: a hidden tab never paints, so
// a rAF loop would neither run the animation nor ever reach its deadline.
const tick = () => {
if (typeof gsap !== 'undefined') { fn(gsap); return; }
if (Date.now() > limit) return; // too late to be an entrance
setTimeout(tick, 24);
};
setTimeout(tick, 24);
} Giving up is the important half. Content is visible by default. An entrance that starts after the reader has begun reading has to hide the text first and fade it back in, which is a flicker on the thing they are looking at. A missed animation is invisible. A late one is a defect.
The comment in that helper records a trap. A hidden tab is never given an animation frame, so a navigation waiting on a tween’s completion hangs, and a requestAnimationFrame poll never reaches its deadline. I found both because my test pane happened to be hidden. The navigation now checks document.hidden and takes the instant path.
The cheapest saving was a header on the HTML
The fourth change was not code at all. Lampstand serves students in Nigeria, and the origin server is on another continent. Measured inside the server, the application answers in a few milliseconds. What a reader in Lagos waits for is the distance.
The public HTML is identical for every visitor, which means a CDN edge close to the reader can hold it. It was not being allowed to. Adding a cache header to the HTML, one that lets the edge keep the page for a short time while browsers still check back, took about 300 ms off the time to first byte. Nothing else in the day came close for the effort involved.
The cost: a published change takes a short while to reach visitors, while a signed-in editor sees it at once. That gap belongs in the owner’s guide, because “my change did not save” is how a client will describe it. Previews and admin views are never cached at the edge.
| Change | What it cost | What it saved |
|---|---|---|
| Server drops the page module this route cannot show | Chasing ReferenceErrors to find shared code living in the wrong file | 21 KB |
React and React-DOM replaced by preact/compat on the public site | A small shim, and a form bug found a day later | 36 KB |
GSAP loaded async instead of defer | Every entrance had to tolerate the library arriving late, or not at all | 31 KB off the blocking chain |
| Cache header on the public HTML | Published changes take a short while to reach visitors | About 300 ms off the first byte |
The before and after figures for the whole page are 480 KB and 250 KB. The rows above are what I measured each change to be worth on its own, and I would not add them up and expect them to explain the full difference.
After the code, the weight was in fonts and in distance
I measured again after the last pass. The two biggest costs on a first visit were no longer JavaScript. They were the fonts and the trip to the origin. That is the normal outcome of doing this well: fix the largest thing and something else becomes the largest thing. Keep polishing the old bottleneck and you can spend a day for two percent.
The fonts were a textbook case of shipping what a page cannot use, one layer down. Both families were variable fonts carrying every weight from thin to black. When I opened them up, the serif was 119 KB and the sans 61 KB, against a 36 KB hero photograph: fonts were 47% of the page. Every serif rule on the site set a single weight. The sans used a range from regular to bold.
Pinning the serif’s weight axis to the one weight in use took it to 50 KB. Clipping the sans to its real range took it to 35 KB. I kept the optical-size axis, because removing it would have saved bytes by changing how the headlines are drawn. Subsetting the glyphs saved single digits: the files were already per-script subsets, so the weight lives in the outlines and the axis data, not in unused letters.
A thumbnail behind an API path is never cached at the edge
The CMS resizes images through a PHP endpoint that caches every variant on disk and returns it with a year-long, immutable cache header. The caching was real. The media picker was still slow in use, and there were two reasons.
The first was that every thumbnail woke PHP, even on a cache hit, because the web server sent everything under the API path to PHP, which then read the cached file back out. I measured that at the origin: about 0.9 ms per request through PHP against 0.6 ms as a static file. Real, and far too small to explain a slow picker, though I found it first and called it the cause.
The second reason was the one that mattered. The CDN would not cache the thumbnails at all. The header said immutable, but the address was an API path with its parameters in the query string, so the edge classified every response as dynamic and sent every request back to the origin. Once each variant had its own plain file URL, the second request came back as a cache hit, and each image went from roughly 0.65 s to 0.45 s from my connection. From Lagos, the difference is larger.
function bf_img_variant_key(string $file, int $w, int $h, int $q): string
{
return sha1($file . '|' . filemtime($file) . "|{$w}x{$h}|q{$q}") . '.webp';
}
function bf_img_variant_url(string $file, int $w, int $h = 0, int $q = 82): ?string
{
$path = bf_img_variant($file, $w, $h, $q);
return $path === null ? null : '/media-cache/' . basename($path);
} The name is a hash of the source path, the source’s modification time and the requested size, so it is immutable by construction: replacing an image produces a new name instead of a stale one. The server hands the name out, and the client never builds it. The web server serves the file directly without waking PHP, and the URL pattern it accepts matches only that generated shape. The old dynamic endpoint stays as a fallback, so an SVG or a PDF still resolves.
The same work gave images a ladder of five pre-encoded widths, with quality stepping down as width goes up. A phone that used to download the 1672-pixel hero at 143 KB now takes 36 KB.
When a responsive image looks broken in testing, check what the browser already has. A browser that holds a larger variant will reasonably keep using it instead of downloading the smaller one your srcset asks for, so a warm profile makes a correct setup look wrong. Test with cache-busted candidate URLs before you change anything. And set srcset before src: an image that receives its source first starts that download immediately, and you get two images on the wire for one on screen.
Speed work on a live site is mostly subtraction and verification
Very little of the day was clever. Each change removed something the page paid for and could not use. What took the time was proving nothing broke: the split exposed a shared component in the wrong file, the runtime swap exposed a form that relied on one renderer’s habits, and the late animation library exposed entrances that could leave content half built. None of those shows up by reading the code. After every change, drive the real site on a phone profile, and change things mid-way through forms rather than only submitting them.
The full engagement, including the CMS and how the content model was designed, is in the Lampstand case study.
Ask what the first page can actually show
If your application feels slow on a phone and nothing in the network list looks heavy, open the homepage and list every script it runs. Next to each one, write the part of the page it draws. Anything with an empty second column is code the reader is paying for and cannot use, and that list is usually a better plan for the week than any score.
