Building Web Apps for Low-Bandwidth, Mid-Range-Phone Reality
Most performance advice is written for fast phones on cheap data. Here is what changes when your users are on mid-range Android handsets paying per megabyte, and why we removed the framework from this site.
Most web performance advice is written by people whose users are on fast connections and recent hardware, with data plans they never think about. Under those conditions a heavy page is a mild inconvenience.
Change the assumptions to a mid-range Android phone, a mobile connection and a bundle of data the user bought deliberately, and the same page is a different product. Some of it is simply broken.
This is what we design around, and why this site has no framework on it.
Page weight is your user's money
This is the part that gets lost. When someone loads a 3MB page on a metered bundle, they paid for those megabytes. Page weight is not an abstract metric that maps to a Lighthouse score. It is a cost you are passing to your visitor, without telling them, to display text.
It reframes the tradeoff. "This library is only 80KB" is a reasonable thing to say about a build. It is a less reasonable thing to say about something you are charging a few thousand visitors for, monthly, to render a contact form.
Our own site is the position taken seriously: static HTML, one stylesheet, a handful of small scripts, no web fonts, no framework. Well under half a megabyte for the whole thing. It replaced a React application and it is faster on every measure that matters, with almost nothing left to maintain or patch.
That is not an argument against React. It is an argument that a content site was never the problem React was built to solve.
The CPU is the bottleneck, not the network
The mistake in most optimisation work is treating this as a bandwidth problem. Bandwidth is half of it. The other half is that a mid-range phone has a fraction of the processing power of the laptop the site was built on.
JavaScript has to be downloaded, parsed, compiled and executed. Parsing and execution are CPU-bound, and that is where a large bundle actually hurts on cheap hardware. A page can finish downloading quickly and still be unresponsive for seconds while the main thread works through it.
Two consequences worth internalising:
- A kilobyte of JavaScript costs far more than a kilobyte of image. The image decodes off-thread; the script blocks interaction. Byte-for-byte comparisons of bundle size against media size are misleading.
- Test on a real cheap device. Not a throttled desktop browser, because the throttling approximates network conditions but not the CPU, the thermal behaviour, or the memory pressure. A physical mid-range handset tells you things the emulator will not.
Server-rendered HTML is the default that works
HTML streams. A browser renders it as it arrives, so content appears before the page has finished loading. That property is worth a great deal on a slow connection and it is the thing a client-rendered application gives up.
The client-rendered sequence is: download HTML shell, download JavaScript, execute it, request data, render. Four sequential round trips before anything appears, each one paying the latency penalty in full, and latency to distant servers is its own tax here.
Send HTML. Add JavaScript for the parts that genuinely need interactivity. This ordering is unfashionable in cycles and is nearly always right for content.
Design for intermittent connectivity
Connections here drop when moving between cells, under a loaded tower, or in a building with poor coverage. An application that assumes continuous connectivity fails constantly in normal use.
What helps most, in rough order of value:
- Never lose typed input. The worst failure is a long form submitted on a dropped connection that returns an error and an empty form. Persist locally as the user types and restore on return. This is a small amount of code and it is the difference between annoyance and abandonment.
- Queue writes and sync later. If a user can complete their task offline and have it submit when the connection returns, the connection stops being a blocker.
- Cache the shell with a service worker. Repeat visits then start rendering immediately rather than waiting on the network.
- Say what is happening. A spinner that could mean "loading", "offline" or "broken" leaves the user guessing. "Saved locally, will sync when you reconnect" is a completely different experience from the same state rendered as an ambiguous error.
These are the genuinely useful parts of the progressive web app toolkit. Installability and push notifications get the attention; offline tolerance is what changes whether the thing is usable.
Fonts, images, and the easy wins
Web fonts. A typical family is several hundred kilobytes for a stylistic preference, and it blocks text rendering while it loads. System font stacks cost nothing, render instantly, and look native on every platform. We use them here.
Images. Usually the largest single cost and the easiest to fix. Serve modern formats, size them to their display dimensions rather than shipping a 4000px original into a 400px slot, and lazy-load anything below the fold.
Third-party scripts. Each one is a request to a server you do not control, executing code you did not write, on your critical path. Analytics, chat widgets, tag managers and embeds accumulate quietly. Audit them occasionally and ask what each is actually worth.
A test that keeps you honest
Load your site on a mid-range Android phone, on mobile data, somewhere with two bars. Not the office WiFi.
Most teams find this uncomfortable, which is the point. It is the condition a large share of their users are actually in, and it is nearly never the condition the site was tested under.
Then set a page-weight budget and treat it as a constraint rather than an aspiration. A budget that is exceeded without consequence is a wish. The useful version fails the build.
None of this is a sacrifice
The framing that a fast site is a compromise, that you give up capability for performance, is usually backwards. Most heavy pages are not heavy because they do more. They are heavy because nobody was counting.
Building for constrained conditions produces something better on fast connections too. The reverse is not true.
If you have an application that struggles on the connections your users actually have, get in touch. It is often a smaller job than it looks.