Every modern web framework promises fast client-side navigation. Then you tap a link and the screen sits frozen for 300 to 600 milliseconds. No feedback. Then the next page snaps in.
Making Navigations Instant in v0 showed that instant doesn't have to mean static. Cloudflare's cache write-ups are a reminder to measure the boring parts. We applied both to flags.games: client navigations dropped from a few hundred milliseconds to under a frame (<16ms), and Playwright tests fail the build if that ever regresses.
The server-load trap
SvelteKit (and Next.js, Remix, Nuxt) push you to colocate data with the route. In
SvelteKit that's +page.server.ts or +layout.server.ts.
The catch: if the destination has a server load, the client router keeps the current page mounted until /__data.json returns.
The tap already happened. The UI has not.
Tap a link
0msThe pointer event fires. Nothing on screen changes yet.
Fetch __data.json
~150–400msA serverless function runs. The old page stays frozen — no spinner, no unmount.
Swap routes
thenSvelteKit unmounts the old page and mounts the new one.
Flags start loading
+200msCards sit empty until images arrive and decode.
Several of our routes were hitting the server just to read data that was already in the JavaScript bundle:
challenges/+layout.ts(was+layout.server.ts): checks a static dictionary to see if a challenge slug is public. Every mode switch still waited on__data.json.flag/[country]/+page.ts(was+page.server.ts): facts and colors from compiled TypeScript. Opening a country from Study paid a Vercel round-trip first.leaderboard/countries: stalled navigation to read the visitor-country header off the request.
Zero-blocking shells
Goal: no server round-trip on client navigation. Tap a link, and the destination shell mounts in the same frame.
Shell mounts
< 1 frameLayout and page appear immediately. The old page is gone.
Code already in memory
0msIdle prefetch ran preloadCode for the routes you can actually reach from here.
Flags already decoded
0msToday's Daily targets and the first Solo pool sit in the image cache.
Live data fills the shell
streams inConvex subscriptions arrive without holding the UI hostage.
1. Universal loads, still prerendered
We converted challenges/+layout.server.ts, help/+layout.server.ts, and flag/[country]/+page.server.ts into universal +layout.ts / +page.ts files.
export const prerender = true;
export const entries = () =>
FLAG_PAGE_COUNTRY_SLUGS.map((country) => ({ country }));
export const load: PageLoad = async ({ params }) => {
const code = convertPageSlugToCountryCode(params.country);
if (!code) error(404, "Not found");
return {
code,
country: getCountryByCode(code),
facts: getFactsByCode(code) ?? null,
details: await loadFlagDetail(code),
};
};Production still emits static HTML for crawlers (prerender + entries). In-app navigation runs the load function on the client and skips
the network hop.
2. Hoist request headers to the root
Visitor country is computed once in routes/+layout.server.ts on the
document request. SvelteKit keeps root layout data across client navigations, so /leaderboard/countries reads data.visitorCountryCode without its own server load.
Hover prefetch is a desktop-only trick
data-sveltekit-preload-data="hover" (and Next.js hover prefetch) buys 80–200ms between mouseenter and click. On a phone,
hover never fires. The tap is the first signal, and only then does the browser start
downloading the route chunk over cellular.
We don't wait for the tap. After home paints, an idle callback preloads the routes
people actually hit next — Daily first, then Solo — via SvelteKit's preloadCode / preloadData. Slow-2G and Save-Data skip it.
export function prefetchRoute(href: string) {
void preloadCode(href);
void preloadData(href);
}
export function scheduleIdleRoutePrefetch(hrefs: string[]) {
const start = () => {
if (isConstrainedNetwork()) return;
for (const href of hrefs) prefetchRoute(href);
};
requestIdleCallback(start, { timeout: 2500 });
}By the time someone scrolls to Play Solo, the module is compiled and sitting in memory.
Viewport IntersectionObserver preloading is the same idea with a wider net;
we prefetch a short known list instead of every link on screen.
Warm the flags while home is idle
A fast code swap still looks broken if the next screen is empty cards. Flag artwork is the hero asset, so we decode it before the tap.
Once home is idle, requestIdleCallback warms:
- The five target flags for today's Daily challenge.
- The first pool of Solo candidates.
prefetchFlagCodes(immediateCodes, { priority: "high" });
requestIdleCallback(() => {
prefetchFlagCodesBatched(deferredCodes, { priority: "low" });
}, { timeout: 4000 });Those images land in the browser cache (and the GPU decode cache). Tap Play, and the flags paint on frame one.
Before / after
Navigation latency by journey
Chrome DevTools Fast 4G, 4× CPU slowdown.
Challenges hub
/challenges/*
52× faster
Dropped the blocking challenges/__data.json round-trip.
Flag fact sheets
/flag/[country]
34× faster
Moved static facts into a universal +page.ts load.
Leaderboard tabs
/leaderboard/countries
34× faster
Visitor country now lives on the root layout, not a child server load.
Mobile taps
nav cards
25× faster
Idle-prefetch route chunks. Hover never fires on a thumb tap.
First flag paint
Daily & Solo
70× faster
Decode today's flags into the image cache while home is idle.
After times sit under a frame. The bars are linear, which is why the green ones look like a mistake — they aren't. 8ms next to 420ms is a sliver.
Lock it with instant()
The useful bit in Vercel's v0 write-up: these wins rot unless a test fails when someone (or an agent) puts a server load back on the route.
Our Playwright helper slaps a 2-second RTT and a ~50 kb/s pipe on the page via CDP, then asserts the destination shell is attached within 750ms. If the navigation needs the network, it misses the window.
export async function instant(
page: Page,
action: () => Promise<void>,
options: InstantNavigationOptions,
) {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Network.emulateNetworkConditions", {
latency: 2000,
downloadThroughput: 6400,
connectionType: "cellular3g",
});
try {
await action();
await expect(options.shell).toBeAttached({ timeout: 750 });
} finally {
await cdp.detach();
}
}Smoke suite covers the paths that matter:
test("navigating from home to solo play is instant", async ({ page }) => {
await gotoAppRoute(page, "/");
const link = await prepareLink(page, "/play");
await instant(page, () => link.click(), {
destinationPath: "/play",
shell: page.getByRole("heading", { name: "Solo flag quiz" }),
usable: true,
});
});Reintroduce a blocking server load and CI goes red before it ships.
Takeaways
- Don't hide static data behind a server load. Dictionary checks, URL params, bundled metadata — universal loads.
- Hover prefetch does not exist on mobile. Idle-prefetch the next routes, or observe links as they enter the viewport.
- Warm the pixels, not just the JS. Skeletons are fine. Decoded images on frame one are better.
- Test for instant, or it won't stay instant. Throttle the network in CI and assert the shell still mounts.