1. Fourteen Seconds on a Phone Somebody Actually Owns
I bought a Moto G Power for £129 in a supermarket, mostly so I could stop arguing about hypotheticals. The first thing I loaded on it was a client's product page — a mid-market outdoor equipment retailer whose Lighthouse mobile score was 78 and whose team considered mobile performance a solved problem.
The page painted in about four seconds, which felt slow but survivable. Then I tapped the size selector. Nothing happened. I tapped it again. Still nothing. At around fourteen seconds after navigation the page suddenly caught up, processed both taps, opened the size drawer and immediately closed it again, and I was back where I started with no idea what I had done wrong.
Their analytics showed a 3.2% conversion rate on desktop and 0.9% on mobile, and the working theory in the business was that mobile shoppers browse and desktop shoppers buy. That is a real phenomenon and it was not what was happening here. What was happening was that on the device a large share of their customers actually held, the page was unusable for the first ten seconds and mildly hostile for the next ten.
The Lighthouse score of 78 was not lying, exactly. It was measuring a simulated device at a 4× CPU slowdown from a fast development laptop, which lands somewhere around a 2019 flagship. The Moto G is not that. Nothing in their process had ever put the site in front of a device below that line.
This article is about designing and budgeting for that phone rather than the one in your pocket. Not responsive layout — the fundamentals of building a responsive site are a separate topic and I am assuming them. This is about the device: what a slow CPU does to JavaScript, how to set a budget that means something, the interface decisions that only matter at arm's length on a bus, and how to test on hardware rather than on an idea of hardware.
2. The Device Gap Is Wider Than the Marketing Suggests
Phone benchmarks are reported as flagship numbers, and flagship numbers have improved enormously. Single-core performance on a current iPhone is competitive with a desktop CPU. That fact is genuinely irrelevant to most commerce traffic.
What matters is the distribution. In the UK and much of Europe, median device age at time of use is somewhere around three years and the median device was mid-range when it was new. In markets where a large share of your growth is coming from, it is worse. The device your customer holds is not the device your designer holds and is very often not the device your QA team holds either.
Rough shape of the gap, using single-core scores as a proxy for how fast JavaScript runs:
| Device | Class | Relative JS speed | Approx. share of a typical UK storefront's mobile traffic |
|---|---|---|---|
| Developer MacBook Pro (M-series) | Reference | 1.0× | — |
| Current-generation iPhone | Flagship | 1.1–1.3× slower | 15–25% |
| iPhone from 4 years ago | Ageing flagship | 2–3× slower | 20–30% |
| Mid-range Android, current | Mid | 4–6× slower | 20–30% |
| Budget Android, 2–3 years old | Low | 8–12× slower | 10–20% |
| Budget Android, 4+ years old | Very low | 15–25× slower | 5–10% |
Those shares vary hugely by sector and country and you should get your own from analytics rather than trusting a table. The point is the bottom two rows. If a quarter of your mobile traffic is on hardware eight to twenty times slower than your laptop, a 200ms task on your machine is a two-to-four-second freeze for them.
And it is worse than the arithmetic suggests, for three reasons that do not show up in a benchmark score.
Thermal throttling. Budget phones have small batteries, no vapour chamber, and aggressive thermal governors. Sustained load — which is exactly what parsing a 900KB JavaScript bundle is — causes the SoC to downclock within seconds. A benchmark measures a cold burst. A page load measures the sustained case, where the effective speed can be half the headline.
Memory pressure. A 3GB or 4GB device with Chrome, a couple of background apps, and the system UI is frequently paging. Loading a heavy page triggers garbage collection and, on the worst devices, tab discards. If your users report "the page reloaded itself when I came back from the payment app", that is a tab discard, and it is a memory problem, not a network one.
Slow storage. eMMC rather than UFS on the cheapest devices means cache reads are slow. A warm cache on a budget phone is not the free win it is on a laptop.
3. CPU Is the Constraint, Not Bandwidth
This is the single most useful reframing I can offer, and it contradicts how most performance work is prioritised.
Mobile bandwidth in most developed markets is fine. 4G gives you several megabits, 5G more. Latency is the real network problem and it is bounded — a few hundred milliseconds of round-trip cost, which you can attack with fewer origins and better caching.
CPU is not bounded in the same way. A 900KB JavaScript bundle downloads in about a second on 4G. On a budget Android it then takes four to eight seconds to parse, compile, and execute, and during most of that time the main thread is blocked and the page does not respond to touch. Shipping the same bytes over a faster connection does not help at all.
The rough exchange rate I use for planning, for compressed JavaScript on a mid-range Android: roughly 1ms of main thread time per KB transferred for parse and compile, plus whatever the code actually does when it runs. On a low-end device, double it. So 300KB of compressed JS is around 300–600ms of unavoidable processing before any of your own logic runs, and a framework that then hydrates a page of components will spend multiples of that again.
This is why bundle size limits framed in kilobytes are more useful than they look: the kilobytes are a proxy for milliseconds, and milliseconds on the main thread are the thing that makes a tap do nothing.
What the main thread is doing while nothing happens
When a user taps and nothing happens, the main thread is busy with one of five things. Knowing which one you have determines the fix, and the profiler tells you directly.
Script evaluation — the bundle is being compiled and executed. Fixed by shipping less of it, or later.
Long tasks in your own code — a 400ms function that formats prices for 200 grid items. Fixed by breaking it up or moving it off the main thread.
Style and layout — the browser recalculating geometry, often because JavaScript wrote to the DOM and then read a layout property, forcing a synchronous reflow in a loop. This one is invisible in bundle size and can dominate.
Garbage collection — memory pressure from allocating heavily in a loop. Shows up as regular sawtooth pauses.
Third-party scripts — someone else's code, on your main thread, that you did not write and cannot profile easily.
On the outdoor retailer's page, the fourteen seconds broke down as roughly: 1.2s script evaluation for their own bundle, 2.1s for a personalisation vendor, 4.8s of hydration across a component tree that included the entire footer, 1.4s of forced synchronous layout inside a sticky-header script that read offsetTop on every scroll event, and the rest scattered across analytics, a chat widget, and garbage collection.
4. Making Interaction Latency Visible
Interaction to Next Paint is the metric that captures this, and it is the one that most directly maps to "I tapped it and nothing happened". It measures, across the whole page visit, the worst-case latency from a user interaction to the next frame that reflects it.
The threshold is 200ms for a good rating. On a budget Android with a hydrating framework page, INP values above 1,000ms are routine and values above 3,000ms are not unusual during load.
Measure it in the field with attribution, because an aggregate INP number tells you that something is slow and nothing about what.
// INP with enough attribution to act on. The target element and the phase
// breakdown are what turn "INP is 640ms" into "the size selector's click
// handler is doing 500ms of work".
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution }) => {
navigator.sendBeacon('/rum/inp', JSON.stringify({
value: Math.round(value),
rating,
target: attribution.interactionTarget, // CSS selector of what was tapped
type: attribution.interactionType, // 'pointer' or 'keyboard'
// The three phases. Which one dominates tells you the fix:
inputDelay: Math.round(attribution.inputDelay), // main thread was busy already
processingDuration: Math.round(attribution.processingDuration), // your handler is slow
presentationDelay: Math.round(attribution.presentationDelay), // rendering the result is slow
// Device context, so you can segment. Without this the median hides everything.
memory: navigator.deviceMemory ?? null,
cores: navigator.hardwareConcurrency ?? null,
connection: navigator.connection?.effectiveType ?? null,
}));
}, { reportAllChanges: false });
The three phases matter enormously. A large input delay means the main thread was already blocked when the tap arrived — the fix is elsewhere on the page, usually in script evaluation or a long task. A large processing duration means your event handler itself is slow. A large presentation delay means the work you did caused an expensive layout or paint. Three completely different fixes, and without attribution you will guess.
Segment the results by deviceMemory and hardwareConcurrency. A p75 across all devices is dominated by the fast ones and will look acceptable while a third of your users have a broken experience. I look at p75 within the low-memory segment, which is the honest number.
5. Breaking Up Long Tasks
Any task over 50ms blocks input. On a device 10× slower than yours, a 5ms task on your machine is exactly at the threshold, which means work that is invisible to you is a stutter for them.
The pattern that fixes most of it is yielding — returning control to the browser between chunks of work so queued input gets processed.
// A yielding scheduler. scheduler.yield() is the right primitive where
// available (Chrome 129+) because it resumes with higher priority than a
// setTimeout continuation, so your work does not get starved.
const yieldToMain = () =>
('scheduler' in globalThis && 'yield' in scheduler)
? scheduler.yield()
: new Promise(r => setTimeout(r, 0));
export async function processInChunks(items, work, budgetMs = 8) {
let start = performance.now();
for (const item of items) {
work(item);
// Yield when we have used most of a frame, not every N items — the right
// chunk size depends on how slow the device is, which we cannot know.
if (performance.now() - start > budgetMs) {
await yieldToMain();
start = performance.now();
}
}
}
The time-based budget rather than a fixed item count is the important detail. Chunking by "every 50 items" is calibrated to whatever device you tested on; chunking by elapsed milliseconds self-adjusts, so the budget phone yields more often and stays responsive while the fast phone gets through the work quickly.
Where the work is genuinely computational and does not touch the DOM — parsing a large product feed, computing facet counts, sorting several thousand items — move it to a worker and stop competing for the main thread at all.
// Worker for facet computation. The main thread stays free for input while
// this runs, which on a slow device is the difference between a responsive
// filter panel and a frozen one.
const worker = new Worker('/js/facets.worker.js', { type: 'module' });
export function computeFacets(products, filters) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
const handler = (e) => {
if (e.data.id !== id) return;
worker.removeEventListener('message', handler);
resolve(e.data.result);
};
worker.addEventListener('message', handler);
// Transfer rather than clone where possible; structured cloning a large
// array is itself a main-thread cost that can undo the benefit.
worker.postMessage({ id, products, filters });
});
}
Be honest about when a worker is worth it. The message-passing cost is real, structured cloning a large object is a main-thread serialisation cost, and debugging across the boundary is harder. Below about 50ms of work I do not bother. Above about 200ms it is nearly always right.
6. The Hydration Problem
If you are running a JavaScript framework with server-side rendering, hydration is very likely your largest single main-thread cost, and it is the one people are most reluctant to look at.
The shape of the problem: the server renders correct HTML, which paints quickly and looks finished. Then the client downloads the same component tree as JavaScript, re-executes it, and attaches event listeners. Until that finishes, the page looks interactive and is not. This is the worst possible failure mode, because a page that looks unfinished at least tells the user to wait.
On a fast device the gap is 200ms and nobody notices. On the Moto G it was nearly five seconds, and every tap in that window was either dropped or queued to fire confusingly late.
The mitigations, in order of how much work they are:
Hydrate less. The footer does not need JavaScript. Neither does the breadcrumb, the product description, the shipping information panel, or most of the page. Islands architecture — rendering most of the page as static HTML and hydrating only the genuinely interactive parts — routinely cuts hydration cost by 70–80%. Astro, Qwik, and the various partial-hydration modes in React and Vue all give you this; so does simply not using a framework for the static parts.
Prioritise what hydrates first. The add-to-cart button and the variant selector should be interactive before the reviews carousel. Most frameworks let you defer hydration of a component until it is visible or until the browser is idle.
// Defer hydration until the component is near the viewport AND the main
// thread is idle. Order matters: visibility gates what, idle gates when.
function hydrateWhenNeeded(element, hydrate, { rootMargin = '200px' } = {}) {
const run = () => {
if ('requestIdleCallback' in window) requestIdleCallback(hydrate, { timeout: 2000 });
else setTimeout(hydrate, 0);
};
// Already visible? Hydrate at idle straight away.
const observer = new IntersectionObserver((entries, obs) => {
if (!entries.some(e => e.isIntersecting)) return;
obs.disconnect();
run();
}, { rootMargin });
observer.observe(element);
// Escape hatch: if the user interacts before we have hydrated, do it now,
// synchronously, and replay the event. Without this, early taps are lost.
['pointerdown', 'focusin', 'keydown'].forEach((evt) => {
element.addEventListener(evt, function once(e) {
element.removeEventListener(evt, once);
observer.disconnect();
hydrate();
// Re-dispatch so the now-attached handler sees the interaction
queueMicrotask(() => e.target.dispatchEvent(new Event(evt, { bubbles: true })));
}, { once: true, capture: true });
});
}
The event replay in that snippet is the part people skip and it is the part that matters most. Without it, a user who taps during the hydration window gets nothing, and they will tap again — which is how you end up with double-submitted forms and duplicate cart lines.
Reconsider the framework for the pages that matter. An uncomfortable opinion, but: a product page and a checkout are mostly forms and content. The interactivity is a variant selector, a quantity stepper, a gallery, and a submit. That is achievable in about 8KB of hand-written JavaScript. I have replaced a 180KB hydrating product page with progressively-enhanced HTML on two projects and both times INP on low-end devices improved by an order of magnitude. It is not the right answer for a complex configurator. It is very often the right answer for a product page.
7. Third Parties on a Slow CPU
Third-party JavaScript is worse on mobile than the byte counts imply, because you are paying for someone else's code quality on your worst hardware.
The specific behaviours that hurt: synchronous document writes, unthrottled scroll and resize handlers, polling loops, and — the most common — a tag manager loading a dozen tags that each do a small amount of work on load, none of which is individually alarming and which collectively add a second of main-thread time.
Measure it directly rather than arguing about it.
// Attribute long tasks to the script that caused them. Run this on a real
// device via remote debugging and the output is the argument you need for
// the meeting about whether the heatmap tool stays.
const byOrigin = {};
new PerformanceObserver((list) => {
for (const task of list.getEntries()) {
for (const attr of task.attribution) {
// containerSrc is the script URL for tasks caused by an iframe or script
const src = attr.containerSrc || attr.containerName || 'unknown';
let key = 'first-party';
try { key = new URL(src, location.href).origin; } catch {}
byOrigin[key] = (byOrigin[key] || 0) + task.duration;
}
}
}).observe({ type: 'longtask', buffered: true });
// Dump after the page settles
setTimeout(() => {
console.table(Object.entries(byOrigin)
.map(([origin, ms]) => ({ origin, blockingMs: Math.round(ms) }))
.sort((a, b) => b.blockingMs - a.blockingMs));
}, 10000);
Long task attribution is imperfect — it often reports the container rather than the exact script — but on a real device it is usually enough to identify the offender. Bring that table to the conversation. "The personalisation tool costs 2.1 seconds of frozen screen on a Moto G" is a much better argument than "we should have fewer scripts".
My defaults for third parties on mobile: nothing loads synchronously, nothing loads before the load event unless it is genuinely required for the page to function, and anything below the fold loads when it approaches the viewport. Chat widgets in particular should be a button you render yourself that loads the real widget on tap — the widget is typically 300KB and used by under 2% of visitors.
8. Setting a Budget With a Number In It
"Make it fast on mobile" is unenforceable. A budget with a device class and a number attached can be checked in CI and gives someone grounds to reject a change.
The budget I use, adapted per client:
| Metric | Target | Measured on |
|---|---|---|
| Compressed JS, first party, product page | < 120KB | CI bundle analysis |
| Compressed JS, all origins, before load event | < 220KB | Synthetic run |
| Total Blocking Time | < 350ms | Lighthouse, 6× CPU throttle |
| Longest single task | < 180ms | Real device trace |
| INP p75, low-memory segment | < 300ms | Field RUM |
| LCP p75, low-memory segment | < 3.0s | Field RUM |
| Time to first interaction working | < 4s on reference device | Manual, real hardware |
Two notes on that table. The field targets are deliberately looser than the official Core Web Vitals thresholds for the low-memory segment, because holding a budget phone to a 200ms INP is not achievable on a commerce page and a target nobody can hit gets ignored. And the last row is manual on purpose — somebody physically taps the button on the reference device before release. It takes ninety seconds and catches things no automated check does.
Enforce the automatable parts at the PR.
// CI budget check. 6x CPU throttling approximates a mid-range Android from a
// typical CI runner; calibrate this against your reference device rather than
// trusting the number (see the calibration section below).
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
const BUDGET = { tbt: 350, lcp: 3000, cls: 0.1, jsBytes: 220_000 };
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless=new'] });
const { lhr } = await lighthouse(process.env.PREVIEW_URL + '/products/reference-sku', {
port: chrome.port,
formFactor: 'mobile',
screenEmulation: { mobile: true, width: 390, height: 844, deviceScaleFactor: 2.6 },
throttling: { cpuSlowdownMultiplier: 6, rttMs: 150, throughputKbps: 1638 },
});
await chrome.kill();
const jsBytes = lhr.audits['network-requests'].details.items
.filter(i => i.resourceType === 'Script')
.reduce((n, i) => n + (i.transferSize || 0), 0);
const failures = [];
if (lhr.audits['total-blocking-time'].numericValue > BUDGET.tbt) failures.push('TBT');
if (lhr.audits['largest-contentful-paint'].numericValue > BUDGET.lcp) failures.push('LCP');
if (jsBytes > BUDGET.jsBytes) failures.push(`JS bytes ${Math.round(jsBytes / 1024)}KB`);
if (failures.length) throw new Error(`mobile budget exceeded: ${failures.join(', ')}`);
9. Touch Targets and Where the Thumb Actually Reaches
Everything above is about the device being slow. This section is about the device being held in one hand, at arm's length, on a moving train, by someone whose attention is 40% on your page.
The published minimums are 44×44 CSS pixels (Apple) and 48×48dp (Android and the WCAG 2.2 target size criterion at level AA, which allows 24×24 with spacing). Those are minimums, not targets. I design primary actions at 48px minimum height and secondary controls at 44px, with at least 8px of clear space between adjacent targets.
The failure I see most often is not an undersized button — it is adjacent targets with no gap. A size selector rendered as a row of 40px squares with 2px between them produces mis-taps at a rate that shows up in analytics as unusually high variant-change events immediately before an exit.
/* Touch targets: the visual size and the tap size do not have to match.
A small visual chip can carry a large hit area via a pseudo-element,
which keeps a dense size selector looking dense and stops mis-taps. */
.size-chip {
position: relative;
min-width: 44px;
min-height: 44px;
display: grid;
place-items: center;
}
.size-chip::after {
content: '';
position: absolute;
/* Extend the hit area 4px beyond the visual bounds in every direction,
without changing layout. Keep total spacing between chips >= 8px so
the extended areas do not overlap. */
inset: -4px;
}
.size-grid {
display: flex;
flex-wrap: wrap;
gap: 12px; /* 12px, not 2px: this is the mis-tap fix */
}
The thumb zone. On a phone held in one hand, the comfortable arc for the thumb covers the bottom two-thirds of the screen on the side the hand is on. The top corners are the hardest to reach, particularly on the 6.7-inch devices that are now standard. This has a practical consequence that most storefronts ignore: the primary action on a product page should not be at the top, and a sticky bottom bar carrying the add-to-cart is not a dark pattern, it is ergonomics.
Do it properly though. A fixed bottom bar must account for the home indicator on iOS and the gesture area on Android, or the bottom of your button is under the system UI.
.sticky-buy-bar {
position: fixed;
inset: auto 0 0 0;
/* env() keeps the button clear of the home indicator / gesture bar.
Without this, the bottom ~34px of the bar is untappable on modern iPhones. */
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
/* Reserve the same space at the end of the document so the bar never
covers the last of the content — a shift the user cannot scroll past. */
}
body {
padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
And it needs viewport-fit=cover in the viewport meta for env() to return anything other than zero, which is the reason most implementations of this are silently broken.
The 100vh problem
A full-height hero using height: 100vh on mobile is taller than the visible area, because vh refers to the viewport with the browser chrome hidden, and the chrome is visible until you scroll. The result is that your call to action sits just below the fold on first load.
The fix is the dynamic viewport units, which are well supported now:
.hero {
/* svh = smallest viewport (chrome visible) — content always fits.
lvh = largest, dvh = dynamic and changes as chrome hides, which causes
layout shift mid-scroll and is almost never what you want. */
min-height: 100svh;
}
10. Viewport, Zoom, and the 16px Rule
Three small things that cause disproportionate damage.
Do not disable zoom. user-scalable=no and maximum-scale=1 still appear in themes, usually copied from a 2013 tutorial about preventing "accidental zoom". They break the site for anyone with low vision, they are a WCAG failure, and iOS has ignored them since version 10 anyway. Remove them. The correct viewport meta for a commerce site is:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
Inputs below 16px trigger an iOS zoom. Safari zooms the page when a text input with a font size under 16px receives focus, and it does not zoom back out afterwards. The user is left on a zoomed page mid-form, scrolling sideways to find the next field. This is one of the most reliable causes of checkout abandonment I have ever measured and it is a one-line fix.
/* 16px minimum on anything focusable that takes text. The zoom this prevents
is not recoverable by the user without pinching back out mid-form. */
input[type="text"], input[type="email"], input[type="tel"],
input[type="number"], input[type="password"], input[type="search"],
select, textarea {
font-size: max(16px, 1rem);
}
Font sizes generally. 14px body text is common in desktop designs and is genuinely hard to read at arm's length on a bus. 16px is the floor for body copy; 17–18px reads better on a phone. Line height at 1.5 or above, line length constrained — though on a 375px viewport that constraint is automatic. Prices and stock messages in particular should be larger than the design system's caption size, because they are what the user is actually looking for.
Use rem rather than px for type so that a user who has raised their default font size in the OS gets it. A surprising number of older users have, and a site built entirely in pixels ignores them completely.
11. Keyboards, Input Types, and Autofill
Every unnecessary keystroke on a mobile checkout costs conversion, and most of the fixes are attributes rather than code.
<!-- inputmode drives which keyboard appears; type drives validation and
autofill. They are not the same thing and getting them confused is why
so many forms show a QWERTY keyboard for a postcode. -->
<label for="email">Email</label>
<input id="email" name="email" type="email"
autocomplete="email" inputmode="email"
autocapitalize="off" autocorrect="off" spellcheck="false" />
<label for="phone">Mobile number</label>
<input id="phone" name="phone" type="tel"
autocomplete="tel" inputmode="tel" />
<label for="postcode">Postcode</label>
<!-- Not type="number": that strips leading zeros and shows spinners.
text + a numeric-ish inputmode is the correct combination. -->
<input id="postcode" name="postcode" type="text"
autocomplete="postal-code" inputmode="text"
autocapitalize="characters" autocorrect="off" />
<label for="cc-number">Card number</label>
<input id="cc-number" name="cc-number" type="text"
autocomplete="cc-number" inputmode="numeric"
pattern="[0-9\s]{13,19}" />
autocomplete is the one with the largest measurable effect, because it enables the browser's stored address and card autofill. A checkout with correct autocomplete tokens can be completed in three taps. The same checkout with autocomplete="off" — which some teams still add believing it improves security — takes ninety seconds of typing on a phone. It does not improve security; browsers largely ignore it on address fields now precisely because of this misuse.
The other keyboard problem is the virtual keyboard covering the field being typed into. Browsers usually scroll the focused element into view, but a fixed footer or a sticky bar frequently ends up on top of it. Test this on a real device with a real keyboard; it is invisible in an emulator.
// The VirtualKeyboard API (Chromium) lets you handle the keyboard explicitly
// rather than fighting the browser's default scroll behaviour. Feature-detect;
// Safari does not have it and relies on visualViewport instead.
if ('virtualKeyboard' in navigator) {
navigator.virtualKeyboard.overlaysContent = true;
navigator.virtualKeyboard.addEventListener('geometrychange', (e) => {
const { height } = e.target.boundingRect;
document.documentElement.style.setProperty('--keyboard-height', `${height}px`);
});
} else if (window.visualViewport) {
// Fallback: visualViewport shrinks when the keyboard appears on iOS
const update = () => {
const hidden = window.innerHeight - window.visualViewport.height;
document.documentElement.style.setProperty('--keyboard-height', `${Math.max(0, hidden)}px`);
};
window.visualViewport.addEventListener('resize', update);
update();
}
12. Testing on Hardware You Actually Own
Emulation is not testing. DevTools device mode changes the viewport and the user agent; it does not change the CPU, the GPU, the memory, the thermal behaviour, or the touch input pipeline. CPU throttling gets you closer and is still an approximation of a device rather than a device.
What I recommend every commerce team buys, total cost under £400:
One current budget Android — a Moto G, a Samsung A-series at the lower end, or whatever is on the shelf at that price. This is your reference device and the one you tap the button on before release.
One four-year-old iPhone, bought second-hand. Safari behaves differently enough from Chrome that emulating it is not adequate, and an older iPhone catches the Safari-specific issues without being unrealistically slow.
Optionally one genuinely old Android, five or six years, for the occasional reality check. Not for routine testing — it will fail things you have decided not to support — but useful once a quarter.
Keep them charged, on the office wifi, and in a drawer somebody knows about. The failure mode of a device lab is that it becomes a shelf of dead phones nobody has unlocked in eight months.
Remote debugging the reference device
Chrome on Android connected over USB gives you full DevTools against the real device, including a real performance trace. This is the single highest-value diagnostic in mobile performance work and a lot of people have never done it.
# Enable developer options and USB debugging on the phone, then:
adb devices # confirm the device is authorised
# Visit chrome://inspect/#devices on the desktop; the phone's tabs appear.
# Forward a local dev server to the device so you can profile a branch
adb reverse tcp:3000 tcp:3000 # phone's localhost:3000 -> your machine
# Capture a trace non-interactively for CI or for a before/after comparison
adb shell am start -a android.intent.action.VIEW \
-n com.android.chrome/com.google.android.apps.chrome.Main \
-d "https://staging.example.com/products/reference-sku"
The performance panel trace from a real device is different from a throttled desktop trace in ways that matter. You see the actual thermal behaviour, the actual GC pauses, the actual decode time for your images on that GPU, and — the one that surprises people — the actual cost of style recalculation, which is disproportionately expensive on weak mobile GPUs and is invisible under CPU-only throttling.
Calibrating your throttle
Since you cannot run every CI check on a physical phone, you need a throttling multiplier that approximates your reference device from your CI runner. The multiplier is not universal — it depends on how fast the runner is — and using Lighthouse's default 4× on a fast runner produces numbers far more optimistic than the real device.
Calibrate it. Run a fixed CPU-bound benchmark on the reference phone and on the CI runner, take the ratio, and use that as the multiplier.
// Run this identically on the reference device (via remote debugging) and on
// the CI runner. The ratio of the two results is your throttle multiplier.
function cpuBenchmark() {
const start = performance.now();
let acc = 0;
// Deliberately dull integer/float work: no JIT-friendly patterns, no DOM,
// no allocation, so it measures raw execution rather than GC or layout.
for (let i = 0; i < 5_000_000; i++) {
acc += Math.sqrt(i) * Math.sin(i % 360);
}
return { ms: Math.round(performance.now() - start), acc };
}
console.log(cpuBenchmark());
On the outdoor retailer's setup the honest multiplier turned out to be 6.8×, against a Lighthouse default of 4×. Their CI had been passing a budget that the reference device missed by 70%. Changing one number in the config surfaced four regressions that had shipped over the previous quarter.
13. A Worked Example
The signage retailer. Shopify with a heavily customised theme, a React-based product page added by a previous agency, roughly 68% mobile traffic, and the 0.9% mobile conversion rate.
Baseline on the reference Moto G. LCP 4.6s. First tap that did anything: 13.8s. INP p75 across all mobile traffic 480ms; INP p75 in the low-memory segment 1,340ms. Total JavaScript before the load event: 812KB compressed across nine origins.
Week one, third parties. Long task attribution identified the personalisation vendor at 2.1s of main-thread time. It had been installed for a homepage recommendation carousel and was loading on every page. Scoped it to the homepage only and deferred it to after load: 2.1s of blocking gone from product pages. Removed a heatmap tool nobody had logged into since 2023, and replaced the chat widget with a rendered button that loads the real widget on tap. Three changes, no design impact, JavaScript before load down to 490KB.
Week two, hydration. The React product page hydrated the whole template including footer, breadcrumbs, and description. Moved to hydrating three islands — variant selector, gallery, add-to-cart — with the event replay pattern shown earlier. Hydration cost went from 4.8s to 1.1s on the reference device.
Week three, the interface. Size chips went from 40px with 2px gaps to 44px with 12px gaps. Sticky add-to-cart bar with proper safe-area handling. Fixed the checkout inputs that were 14px and triggering the iOS zoom. Added the full set of autocomplete tokens, which had been stripped by the previous agency's form component.
Results after six weeks. LCP p75 mobile from 4.6s to 2.4s. INP p75 low-memory segment from 1,340ms to 290ms. First working tap on the reference device from 13.8s to 3.1s. Mobile conversion from 0.9% to 1.6% over the following two months, against desktop that moved from 3.2% to 3.3% in the same period — which is the comparison that made the case internally.
What went wrong. The islands migration broke the variant selector's interaction with a Shopify app that injected a "notify me when back in stock" button. The app expected the full React tree and its button stopped appearing on out-of-stock variants. Nobody noticed for nine days because out-of-stock variants are a small share of traffic, and we found it through a support ticket rather than any test. The lesson I took: enumerate the apps and injected scripts that depend on your DOM before restructuring it, because they are effectively undocumented consumers of your markup.
What I would do differently. I did the third-party work first because it was easy and it was the right call, but I should have set the budget and calibrated the throttle in week zero rather than week four. For three weeks we were making changes and measuring them against a CI configuration that was too generous to detect regressions, and at least one of the improvements was partially eroded by an unrelated deploy before we noticed.
Also, honestly: I over-invested in the worker-based facet computation. It was the most interesting piece of engineering in the project and it moved INP by about 15ms, because faceting was not on the critical interaction path for most sessions. The size-chip gap change took twenty minutes and probably contributed more to the conversion number. I do not have a clean way to prove that, which is itself part of the lesson.
14. Watching It in the Field
The device segmentation is what makes field data useful here. An aggregate p75 is dominated by fast devices and will look fine while a third of your users cannot use the site.
// Bucket every vitals beacon by a crude device class so dashboards can be
// segmented. deviceMemory is coarse (2/4/8) and Safari does not expose it,
// so treat 'unknown' as its own bucket rather than lumping it with 'fast'.
function deviceClass() {
const mem = navigator.deviceMemory;
const cores = navigator.hardwareConcurrency;
if (mem === undefined && cores === undefined) return 'unknown';
if ((mem ?? 8) <= 2 || (cores ?? 8) <= 4) return 'low';
if ((mem ?? 8) <= 4 || (cores ?? 8) <= 6) return 'mid';
return 'high';
}
// Attach to every beacon; then look at p75 WITHIN 'low' rather than overall.
const context = {
deviceClass: deviceClass(),
saveData: navigator.connection?.saveData ?? false,
effectiveType: navigator.connection?.effectiveType ?? null,
viewport: `${window.innerWidth}x${window.innerHeight}`,
dpr: window.devicePixelRatio,
};
Two things I look at monthly. INP p75 within the low bucket, which is the honest interactivity number. And the ratio of low-bucket to high-bucket conversion rate, which is a proxy for how much your performance problems are costing you — if low-end devices convert at half the rate of high-end ones, that gap is not entirely about affluence.
saveData is worth respecting where you can. A user who has enabled data saver is telling you something explicit, and serving them lower-quality images and skipping non-essential scripts is both polite and easy.
15. Questions People Ask
"Our Lighthouse mobile score is 90. Is that enough?" It tells you the page is fine at 4× CPU throttling from whatever machine ran it, which is probably a fast one. Calibrate the multiplier against a real budget phone and re-run. On the retailer above, the same page scored 78 at 4× and 41 at the calibrated 6.8×. Neither number is wrong; they measure different devices.
"Should we build a PWA or a native app instead?" Almost never as a performance fix. An app moves your slow JavaScript into a shell where it is still slow, and adds an install step that most of your traffic will not complete. Fix the site. The exception is a genuine repeat-purchase business where the app serves retention rather than acquisition, and that is a product decision rather than a performance one.
"How much does mobile performance actually affect conversion?" The honest answer is that the correlation is well established and the causation is hard to isolate, because you rarely get to change only performance. What I can say from projects where the change was reasonably isolated: improvements of one to two seconds in LCP on mobile have been associated with conversion changes in the 5–20% relative range. That range is wide because it depends enormously on how bad the starting point was. Going from 8s to 4s matters far more than going from 2.5s to 2.0s.
"Is mobile-first still meaningful as a design approach?" The CSS methodology — write the small-viewport styles first, add complexity with min-width queries — is still the right default because it produces less CSS and fails better. The broader idea, designing the small screen first and expanding, I would state differently now: design for the constrained device first, of which the small screen is only one constraint and not the most damaging one. A team can be perfectly mobile-first in layout and still ship 800KB of JavaScript.
"What about container queries and modern CSS?" Genuinely useful and largely orthogonal to this article's subject. Container queries let components respond to their own space rather than the viewport, which makes a component library far more reusable. They do not make anything faster. Use them because they simplify your CSS, not as a performance measure.
"Should I use a lighter framework?" Framework choice matters less than how much of the page you hydrate. I have seen a 40KB framework produce worse INP than a 130KB one because the smaller-framework site hydrated everything and the larger one used islands. Measure hydration cost on a real device before blaming the framework.
"Can we detect slow devices and serve a lighter page?" You can, using deviceMemory and hardwareConcurrency as a crude signal, and I have done it for specific expensive features — skipping a parallax effect, loading fewer carousel slides. What I would not do is serve a structurally different page, because you then have two experiences to maintain and test, and the signal is coarse enough that you will misclassify people. Make the one page fast enough.
"How do I convince stakeholders to fund this?" Put the reference device in their hands. Every performance argument I have made with numbers has been less effective than handing a director a £129 phone and asking them to buy something from their own site. It takes two minutes and it ends the discussion.
16. The Order I'd Work In
Buy the phone. Under £150, in a supermarket, this week. Load your product page on it and try to complete a purchase over a mobile connection with the office wifi turned off. Whatever you find in those five minutes will reorder your backlog more effectively than any audit.
Calibrate your CPU throttle against that phone before you change anything, so that your before-and-after numbers mean something and your CI can actually catch a regression. This is a fifteen-minute job that most teams skip and it invalidates a lot of subsequent measurement when they do.
Run long task attribution on the real device and look at where the main thread time goes. In my experience the top two entries are a third-party script that could be deferred and hydration of components that do not need to be interactive. Both are addressable without a redesign.
Then the small interface fixes, which are cheap and disproportionately valuable: 16px minimum on inputs, correct autocomplete tokens, gaps between adjacent tap targets, safe-area handling on anything fixed to the bottom, and removing user-scalable=no from wherever it is hiding.
Set the budget, with device-segmented field targets, and put the automatable parts in CI on the day you finish rather than in the next quarter's plan.
And keep tapping the button on the real device before every release. It is the least sophisticated thing in this article and it is the check that has caught the most. The outdoor retailer had monitoring, a performance vendor, and a green Lighthouse score, and the one thing nobody had done was hold the phone their customers hold and try to buy something.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
UX Design Principles for High-Converting Ecommerce Stores
An examination of visual hierarchy, fluid typography scales, and structural layouts.
-
E-commerce Performance Optimization: The Edge Delivery Blueprint
Advanced strategies for sub-second rendering, edge caching, and image optimization pipelines.
-
PWA Architecture for Next-Generation Mobile Commerce
Building resilient, offline-capable Progressive Web Apps that rival native mobile experiences.