nextjs · tutorial · agents
App Router Feels Broken on 3G. It's Not. Fix This.
Blank screens on slow networks mean missing loading states, not a broken router. loading.tsx plus streaming plus skeletons fix it. Inside.

The app worked perfectly (on office fiber)
Demo day, client's office, conference Wi-Fi from 2009. The beautiful App Router app opens to... white. Five seconds of white. Then content snaps in all at once, gorgeous and three seconds too late. The client smiles politely. Everyone knows that smile. Nobody buys white screens.
Back home on fiber: instant, perfect, smug. The router wasn't broken — the loading states were missing. An app performing exclusively for developers on good connections serves an audience of one.
App router slow network complaints are almost always this: no broken code, just silence where feedback belongs. The fix isn't speed. It's manners.
Silence vs streaming on slow networks
| 3G load | No loading states | Streaming + skeletons |
|---|---|---|
| 0–1s | White void | Shell + shimmer (alive!) |
| 1–3s | Still white (user leaving) | Sections popping in |
| 3–5s | Snap-in (too late, trust gone) | Complete (watched happily) |
| Bounce rate | High (abandoned at white) | Low (progress = patience) |
| Perceived quality | "Broken" | "Fast" (same bytes!) |
Three things conference Wi-Fi taught me:
- White screens read as broken, always. Users can't distinguish "loading beautifully" from "crashed silently" — both look identical, and "broken" is the default assumption after one second. Every millisecond of blank is a trust withdrawal; skeletons are deposits. Same bytes, opposite verdicts.
- Slow networks punish waterfalls most. Nested data fetching (page waits for user, component waits for orders, widget waits for stats) serializes on 3G into seconds-long chains that fiber hides. Streaming boundaries parallelize perception (shell now, sections as ready) even when bytes stay serial. Structure for the worst network, enjoy the best.
- Developers never see their own silence. Localhost responds in milliseconds; staging sits on office fiber; throttling tools gather dust in devtools drawers. The silence is invisible from fast networks by construction — which is why every team needs a throttled-device ritual, not just good intentions.
The slow-first-click post covered prefetch. This one covers grace under slowness.
App router slow network manners
Shells, boundaries, budgets — perceived instant on actual 3G.
Step 1 — Give every route a loading.tsx shell.
export default function Loading() {
return (
<main>
<div className="h-8 w-48 animate-pulse rounded bg-stone-200" /> {/* title ghost */}
<div className="mt-4 grid gap-4"> {/* card ghosts, layout-identical */}
{[1, 2, 3].map((i) => <div key={i} className="h-32 animate-pulse rounded-2xl bg-stone-100" />)}
</div>
</main>
);
}Layout-identical skeletons (same spacing as real content — no layout shift when truth arrives) paint in milliseconds while data streams. Users see structure forming, not void persisting. Every route without loading.tsx is a white screen wearing a route costume — audit with a checklist, not hope.
Step 2 — Bound Suspense around slow islands.
import { Suspense } from "react";
export default function Dashboard() {
return (<main>
<SummaryCards /> {/* fast: renders with shell */}
<Suspense fallback={<RevenueGhost />}> {/* slow island: streams in */}
<RevenueChart />
</Suspense>
</main>);
}Slow islands stream independently (chart arrives when ready, page never waits) instead of blocking the whole route on the slowest query. Identify islands by timing (anything over 500ms on throttled profiles gets a boundary). Fast shell plus slow islands equals perceived instant with honest progress — the only pattern that survives real networks.
Step 3 — Budget bytes for the worst network.
# throttled ritual (monthly, real device or devtools Slow 3G):
# JS per route <200KB (code-split ruthlessly), images AVIF + sized, fonts subset
# measure: Lighthouse performance on Moto G4 profile (the honest benchmark)Byte budgets make speed structural (small payloads load fast everywhere) instead of situational (fast on fiber, white on 3G). Code-split per route, compress images at upload, subset fonts to used glyphs. The ritual (one throttled pass monthly) catches regressions before users do — silence creeps back without scheduled listening.
That's the pattern — graceful on 3G, instant on fiber.
Throttled-device scores, before and after
| Slow-3G load | Before (no states) | After (shells + islands) |
|---|---|---|
| First paint | 3.2s white void | 0.4s shell |
| Bounce (throttled test) | ~35% | ~8% |
| "Broken" feedback | Regular | Zero since |
| Lighthouse Moto G4 | 41 (red) | 89 (green) |
| Ritual cost | — (ignorance) | 1 throttled pass/month |
BoringPush shells paint before data streams.
Being straight: skeletons double some UI work (every meaningful view needs its ghost twin) and Suspense boundaries complicate data flow (props split across async seams). Priced against 35% throttled bounce plus demo-day smiles: ghosts are cheap, voids are expensive, and the twin pays for itself the first time a client loads over hotel Wi-Fi.
FAQ
Is app router slow network fix just adding spinners everywhere?
No — spinners alone are barely better than white (motion without structure still reads as waiting). Skeletons mirror layout (structure forming), Suspense streams sections (progress with content), spinners mark only tiny waits (button states, <500ms). Hierarchy of feedback: shell first, sections second, spinners last. Spinners everywhere is anxiety with animation.
Should I throttle in devtools or test on real devices?
Both, staged: devtools Slow-3G weekly (fast feedback during builds), real mid-range Android monthly (truth — devtools approximates radio behavior imperfectly). Emulators catch structure issues (missing shells); devices catch reality issues (CPU-bound hydration, memory pressure). Cheap approximation often, expensive truth monthly. Never ship network UX tested only on fiber.
How do loading.tsx shells interact with SEO?
Positively when done right: crawlers see streamed complete content (SSR finishes server-side regardless), users see progressive shells. Skeletons are client-perceived states, not served HTML — Google indexes the final render, humans enjoy the journey. No SEO penalty for good manners; possible ranking gain from real-user metrics (bounce, dwell) improving.
What about offline or airplane-mode handling?
Beyond loading states: offline needs its own UX (cached shell + "you're offline" notice + queued actions), via service workers for app routes. Loading patterns assume eventual arrival; offline assumes none. Build the loading layer first (covers 95% of bad networks), add offline second (covers planes and tunnels). Graceful degradation in layers, each cheaper than the last to add.
Do Server Components make slow networks better or worse?
Better structurally (less JS shipped, streaming native, colocated data), same discipline required (shells, boundaries, budgets still mandatory). RSC removes client-round-trip waterfalls but can't compress bytes or conjure feedback. Architecture upgrades the ceiling; manners still decide the experience. Modern React plus loading discipline is the complete answer — neither alone suffices.
When is "fast enough on 3G" actually reached?
Lighthouse Moto G4 green (90+), throttled bounce under 10%, zero "broken" feedback in support channels. Past these lines, further optimization serves benchmarks over humans. My bar adds the demo-day test: conference Wi-Fi, client watching, zero embarrassment. Tune to humans in bad networks, not scores in good labs.
Keep reading
- Next.js App Router Slow First Click? Fix the 1-5s Delay — the prefetch half of perceived speed
- useRouter Feels Slow? Skip It on First Paint — interaction feedback after routes land
- Next.js Dev Takes 30 Seconds to Boot? Fix That — speed on the builder side
+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)
Command from your phone.
Built so autonomous AI agents build what you want without breaking what's already paying you.
- Built-in analytics — cookie-free mode, $0 forever, no PostHog bill
- SQLite ↔ Supabase: one setting, same field names
- Full digital marketplace & feedback boards included
- 3 payment rails: Stripe, LemonSqueezy & Dodo Payments
- Agent fences: lint gate, read-only data access, auto-rollback
- Ship from your phone via SSH & WireGuard/Tailscale in under a minute
$199
Get BoringPushOne-time payment · Lifetime updates · Unlimited projects.
Keep reading


