Most performance guides are written by people testing on a MacBook over office wifi. The advice is usually correct and usually irrelevant to the users who need it most: someone on a three-year-old Android phone, on 3G, in a place where the connection drops mid-request. I spent a good part of my career building products in that reality, including a remittance product where a failed request wasn't an inconvenience, it was someone's money in an uncertain state. That constraint changes how you think about frontend engineering more than any framework choice does.
This isn't a list of generic performance tips. It's the specific set of decisions that matter once you stop assuming a fast, stable connection and a capable device.
Perceived performance beats raw speed
On a slow connection, the actual latency is out of your control. What's in your control is what the user perceives while they wait. A spinner tells the user "something is happening" but gives them nothing to look at and no sense of progress. A skeleton screen that mirrors the eventual layout does more work with the same wait time, because the user's brain starts parsing the page before the data arrives.
The bigger lever is optimistic UI: update the interface as if the action already succeeded, then reconcile with the server response in the background.
function useOptimisticToggle(initial, mutate) {
const [state, setState] = useState(initial);
const [pending, setPending] = useState(false);
async function toggle() {
const previous = state;
setState(!state); // update immediately
setPending(true);
try {
await mutate(!state);
} catch (err) {
setState(previous); // roll back on failure
throw err;
} finally {
setPending(false);
}
}
return [state, toggle, pending];
}
The rollback path is the part people skip. Optimistic UI without a rollback is just a UI that lies to the user when the network fails, and on unreliable connections, the network fails often enough that this matters.
Retries need a backoff, and mutations need idempotency
On a flaky connection, a request that times out doesn't mean it failed. It might have reached the server and succeeded; the response just never made it back. Naively retrying a POST in that situation can trigger the action twice. For a "like" button, that's harmless. For anything transactional, like moving money or submitting an application, it isn't.
The fix is an idempotency key generated client-side and sent with the request, so the server can recognize a retried request as the same request and return the original result instead of repeating the action:
async function fetchWithRetry(url, options = {}, retries = 3) {
const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID();
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, {
...options,
headers: { ...options.headers, "Idempotency-Key": idempotencyKey },
});
if (!res.ok && res.status >= 500 && attempt < retries) throw new Error("retryable");
return res;
} catch (err) {
if (attempt === retries) throw err;
const delay = 2 ** attempt * 200 + Math.random() * 200; // backoff + jitter
await new Promise((r) => setTimeout(r, delay));
}
}
}
The jitter matters as much as the backoff. If a batch of clients all lose connection to the same flaky cell tower at once, they'll all retry at the same intervals without it, turning a brief network blip into a thundering herd against your API the moment it recovers.
Distinguish "slow" from "offline"
These call for different UI, and conflating them is a common mistake. Offline means stop trying and tell the user clearly. Slow means keep trying, but tell the user it's taking longer than usual rather than leaving them staring at a spinner that looks identical at two seconds and twenty. The navigator.onLine flag and the online/offline window events get you the first signal; a simple elapsed-time threshold on your loading state gets you the second.
The Network Information API (navigator.connection) is worth knowing about, even though support is inconsistent: where it exists, effectiveType and saveData let you make real decisions, like skipping a video autoplay or requesting a smaller image, based on the connection the user actually has instead of the one you tested on.
Bundle size is a UX decision, not a build metric
On a low-end device, JavaScript isn't just slow to download over a bad connection, it's slow to parse and execute once it arrives, because the CPU is also weaker. A bundle that feels instant on a modern laptop can add a full second or more of main-thread blocking on a budget Android device, and that cost doesn't show up in a wifi-and-MacBook test.
Two things pay for themselves disproportionately here: route-based code splitting so users only download what the current screen needs, and being deliberate about dependencies, since a large date-formatting or animation library pulled in for one small feature is a tax paid by every user on every visit. Testing this requires actually turning on CPU throttling (4x slowdown is a reasonable baseline) and a slow-network profile in devtools, not just checking the numbers on your own machine.
Images: decode cost matters as much as file size
Responsive srcsets and modern formats solve the download problem. They don't solve the decode problem: a large image still costs CPU and memory to decode and paint, and that cost is much higher relative to the total budget on a low-end device. A blur-up placeholder (a tiny, heavily compressed version shown immediately, swapped for the full image once decoded) keeps the layout stable and gives the user something meaningful during that gap, instead of a blank box that suddenly pops in.
The actual takeaway
None of this is exotic. Optimistic updates, backoff with jitter, idempotency keys, and deliberate bundle size are all well-known techniques individually. What changes when you build for unreliable networks and low-end devices is the priority order: these stop being edge-case polish and become the baseline the product has to work on for a meaningful share of its users. Test on the conditions your actual users have, not the ones your laptop has, and a lot of these decisions make themselves.
Top comments (0)