10.6 — Mobile & low‑end devices¶
Module 10 · Lesson 6 · 🔴 Advanced · ~30 min
68% of Aurora's sessions and the segment where every problem in this course is 4–12× worse. If your site is good on a laptop and bad here, you don't have a fast site.
The reality¶
| Segment | Share | CPU vs a modern laptop | Typical experience |
|---|---|---|---|
| Desktop | 32% | 1.5× slower | Fine |
| High‑end mobile | 24% | 2× slower | Fine |
| Mid‑tier Android | 38% | 6× slower | Where you're failing |
| Low‑end mobile | 6% | 12× slower | Often unusable |
The mid‑tier segment is the largest and the most neglected. Engineers test on flagships; 38% of the money is on a three‑year‑old mid‑range Android on a congested network.
What 6× means concretely¶
| Task | Your laptop | Mid‑tier Android |
|---|---|---|
| Parse+compile 250 KB of JS | 90 ms | 540 ms |
| Hydrate a PDP | 100 ms | 610 ms |
| Render 48 product cards | 65 ms | 390 ms |
| Decode a 2000px JPEG | 30 ms | 180 ms |
| A "fast" 40 ms interaction | 40 ms | 240 ms 🔴 |
That last row is the point: an interaction that feels instant to you is over the INP threshold for the median user.
Test like your users¶
# Lighthouse with mobile emulation and 4× CPU throttling
npx lighthouse https://www.auroramarket.com/p/wool-overshirt-navy \
--form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4 \
--throttling.rttMs=150 \
--throttling.throughputKbps=1638 \
--output=html --output-path=./mobile.html
// Playwright: throttle CPU and network for every mobile test
import { devices } from '@playwright/test';
export default defineConfig({
projects: [{
name: 'mid-tier-android',
use: {
...devices['Pixel 5'],
launchOptions: { args: ['--force-device-scale-factor=2.75'] },
},
}],
});
// In the test:
const client = await page.context().newCDPSession(page);
await client.send('Emulation.setCPUThrottlingRate', { rate: 6 });
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.6 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 150,
});
Buy three real devices. A ~$150 Android from three years ago, a mid‑range current model, and an older iPhone. Put them on the team's desk. Emulation approximates CPU but not thermal throttling, memory pressure, or a real 4G radio — and nothing changes engineering behavior like watching your own site struggle on a real phone.
Adaptive loading¶
Serve less to devices that can handle less.
// hooks/use-device-capability.ts
'use client';
export function useDeviceCapability() {
const [capability, setCapability] = useState<'high' | 'medium' | 'low'>('high');
useEffect(() => {
const memory = (navigator as any).deviceMemory ?? 8;
const cores = navigator.hardwareConcurrency ?? 8;
const conn = (navigator as any).connection ?? {};
const slowNetwork = ['slow-2g', '2g', '3g'].includes(conn.effectiveType);
const saveData = !!conn.saveData;
if (saveData || memory <= 2 || cores <= 4 || slowNetwork) setCapability('low');
else if (memory <= 4 || cores <= 6) setCapability('medium');
else setCapability('high');
}, []);
return capability;
}
// Use it to skip expensive enhancements
export function ProductGallery({ images }: Props) {
const capability = useDeviceCapability();
return (
<>
<MainImage image={images[0]} />
{/* Zoom is expensive; skip it on low-end devices rather than shipping jank */}
{capability !== 'low' && <ZoomOverlay images={images} />}
{/* Video previews only on capable devices with good connections */}
{capability === 'high' && images.some((i) => i.videoUrl) && <VideoPreview images={images} />}
</>
);
}
Server‑side adaptive loading, using client hints, avoids the client round trip:
// middleware.ts — request client hints, then read them on subsequent requests
export function middleware(req: NextRequest) {
const res = NextResponse.next();
res.headers.set('Accept-CH', 'Sec-CH-UA-Mobile, Sec-CH-Device-Memory, Save-Data, ECT, RTT');
res.headers.set('Critical-CH', 'Save-Data');
const saveData = req.headers.get('save-data') === 'on';
const memory = Number(req.headers.get('sec-ch-device-memory') ?? 8);
const ect = req.headers.get('ect') ?? '4g';
const tier = saveData || memory <= 2 || ['slow-2g', '2g', '3g'].includes(ect) ? 'low' : 'high';
res.headers.set('x-device-tier', tier);
return res;
}
// Server Component reads the tier — decided before any HTML is generated
export default async function ProductPage({ params }) {
const tier = (await headers()).get('x-device-tier') ?? 'high';
const product = await getProduct((await params).slug);
return (
<>
<ProductGallery
images={tier === 'low' ? product.images.slice(0, 3) : product.images}
quality={tier === 'low' ? 60 : 75}
/>
{tier !== 'low' && <Recommendations productId={product.id} />}
</>
);
}
Careful with the cache key. A device tier in the cache key doubles your cache entries. Keep it to two tiers, and only apply it where the saving is worth the fragmentation (7.2).
Save-Data¶
// Respect it — users who set it are explicitly asking for less
const saveData = (await headers()).get('save-data') === 'on';
<Image
src={product.image}
quality={saveData ? 50 : 75}
sizes={saveData ? '50vw' : '100vw'} // request a smaller candidate
{...rest}
/>
Touch interaction latency¶
Mobile has interaction problems desktop doesn't.
// ❌ 300ms click delay on some configurations, and no visual feedback
<button onClick={handleTap}>Add to bag</button>
// ✅ Fast, with immediate feedback
<button
onClick={handleTap}
onTouchStart={preloadWhateverThisNeeds} // start work at touch-start
className="active:scale-95 transition-transform touch-manipulation"
>
Add to bag
</button>
/* Removes the double-tap-to-zoom delay without disabling pinch zoom */
button, a, [role="button"] { touch-action: manipulation; }
/* Never do this — it breaks accessibility for users who need to zoom */
/* <meta name="viewport" content="user-scalable=no"> ❌ */
Touch target sizing is a performance issue too: a mis‑tap costs a full navigation. Minimum 44×44 CSS pixels, with 8px of spacing.
// Small visual control, adequate touch target
<button className="relative p-3 -m-3" aria-label="Add to wishlist">
<HeartIcon className="h-5 w-5" />
</button>
Scroll performance¶
/* Always passive scroll listeners */
/* JS: window.addEventListener('scroll', fn, { passive: true }) */
/* Avoid these on mobile — they're expensive to composite */
.hero { background-attachment: fixed; } /* ❌ */
.card { backdrop-filter: blur(20px); } /* ❌ expensive on low-end GPUs */
/* Momentum scrolling for horizontal rails */
.rail { overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; }
overscroll-behavior: contain on horizontal rails prevents scroll chaining to the page, which
fixes the "I tried to scroll the carousel and the page moved" complaint.
Memory on mobile¶
Low‑end devices have 2–3 GB total, of which the browser gets a fraction. Exceeding it kills the tab — and the user's cart context with it.
The image decode arithmetic (8.4):
A 2000×2000 image decodes to 2000 × 2000 × 4 = 16 MB in memory
30 such images = 480 MB → tab killed on a 3GB device
Correct sizes is a crash‑prevention measure, not just a bandwidth optimization. A 400px
image decodes to 0.64 MB instead of 16 MB — a 25× difference.
Also: - Cap infinite scroll (5–10 pages, then a button) - Virtualize above ~200 items - Bound every client cache - Test long sessions on a real low‑end device, not an emulator
The mobile‑specific budget¶
Tighter than desktop, because the constraints are tighter:
| Metric | Desktop | Mobile |
|---|---|---|
| LCP | ≤ 2.5 s | ≤ 2.2 s |
| INP | ≤ 200 ms | ≤ 180 ms |
| JS (gz) | ≤ 300 KB | ≤ 200 KB |
| Above‑fold images | ≤ 500 KB | ≤ 220 KB |
| DOM nodes | ≤ 3,000 | ≤ 1,500 |
| Main‑thread work before interactive | ≤ 2.5 s | ≤ 1.5 s |
Setting the mobile budget tighter than desktop feels backwards and is correct: mobile has less of everything and more of your users.
The mobile checklist¶
Load
- [ ] Hero/product image ≤ 220 KB, AVIF, correct sizes for a ~390px viewport
- [ ] JS ≤ 200 KB gzipped
- [ ] Fonts subset and self‑hosted; at most one preloaded
- [ ] Third parties deferred or removed
- [ ] Route is cacheable (ISR/PPR)
Interaction
- [ ] Every interaction tested at 6× CPU throttling
- [ ] touch-action: manipulation on interactive elements
- [ ] Touch targets ≥ 44×44 px with spacing
- [ ] startTransition on every expensive update
- [ ] Passive scroll listeners
Visual
- [ ] No layout shift from late content
- [ ] No background-attachment: fixed, no heavy backdrop-filter
- [ ] overscroll-behavior: contain on horizontal rails
- [ ] Viewport meta allows zoom (user-scalable=no is never acceptable)
Memory - [ ] Images sized for the actual slot (decode size, not file size) - [ ] Infinite scroll capped - [ ] Long‑session test passes on a real low‑end device
Forms
- [ ] Correct inputMode on every field
- [ ] autoComplete on every checkout field
- [ ] enterKeyHint where it helps
- [ ] Uncontrolled inputs
Diagnosis order¶
Mobile much worse than desktop?
├─ Ratio > 3× on LCP? → network/image bound → [2.1], [2.4]
├─ Ratio > 3× on INP? → CPU bound → [4.4], [5.x], [2.3]
├─ Only low-end affected? → memory or CPU ceiling → [8.4], adaptive loading
└─ Only slow networks? → payload size → [4.1], [2.1]
Mobile INP > 180ms?
├─ Test at 6× CPU — does it reproduce? → yes: CPU bound, [6.2]
├─ Bundle > 200KB? → [4.1], [4.3]
├─ Hydration > 200ms? → [4.4]
├─ Third parties during load? → [2.3]
└─ DOM > 1,500 nodes? → [5.4]
Lab 10.6 — The real‑device test¶
- Get a mid‑tier Android (three years old, ~$150 when new). Not an emulator.
- Use your site on it for ten minutes on a real cellular connection, outside the office wifi. Browse, filter, add to cart, start checkout. Write down every moment it felt slow.
- Compare that list to your dashboards. What did you feel that your metrics didn't show?
- Remote‑debug it (
chrome://inspect) and record a trace of your worst interaction. - Set your DevTools default to 6× CPU throttling. Leave it there permanently.
- Add a mid‑tier device project to your Playwright config and run the interaction tests against it.
- Implement adaptive loading for one expensive feature and measure the delta on the low tier.
That first step changes more behavior than any document. Every engineer on the team should do it once a quarter.