FLAGS
Home
Daily
Play
Multiplayer
Challenges
Leaderboards
World Activity
Social
Explorer
Learn
Help
Contact
All posts
performanceengineeringsveltekit

Making Every Navigation Instant: How We Eliminated Click Latency

How we dropped blocking server loads, idle-prefetch route chunks, pre-warm flag images, and lock instant navigations with a Playwright helper.

September 3, 2026 · 6 min read

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.

Blocking navigation 250–600ms
  1. Tap a link

    0ms

    The pointer event fires. Nothing on screen changes yet.

  2. Fetch __data.json

    ~150–400ms

    A serverless function runs. The old page stays frozen — no spinner, no unmount.

  3. Swap routes

    then

    SvelteKit unmounts the old page and mounts the new one.

  4. Flags start loading

    +200ms

    Cards 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.

Instant navigation < 16ms
  1. Shell mounts

    < 1 frame

    Layout and page appear immediately. The old page is gone.

  2. Code already in memory

    0ms

    Idle prefetch ran preloadCode for the routes you can actually reach from here.

  3. Flags already decoded

    0ms

    Today's Daily targets and the first Solo pool sit in the image cache.

  4. Live data fills the shell

    streams in

    Convex 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.

apps/web/src/routes/flag/[country]/+page.ts
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.

apps/web/src/lib/navigation/route-prefetch.ts
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.
apps/web/src/lib/components/game/flag-warmup.ts
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.

Before After

Challenges hub

/challenges/*

52× faster

Before
420 ms
After
8 ms

Dropped the blocking challenges/__data.json round-trip.

Flag fact sheets

/flag/[country]

34× faster

Before
380 ms
After
11 ms

Moved static facts into a universal +page.ts load.

Leaderboard tabs

/leaderboard/countries

34× faster

Before
310 ms
After
9 ms

Visitor country now lives on the root layout, not a child server load.

Mobile taps

nav cards

25× faster

Before
350 ms
After
14 ms

Idle-prefetch route chunks. Hover never fires on a thumb tap.

First flag paint

Daily & Solo

70× faster

Before
280 ms
After
4 ms

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.

apps/web/e2e/instant.ts
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:

apps/web/e2e/smoke.instant-navigation.spec.ts
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

  1. Don't hide static data behind a server load. Dictionary checks, URL params, bundled metadata — universal loads.
  2. Hover prefetch does not exist on mobile. Idle-prefetch the next routes, or observe links as they enter the viewport.
  3. Warm the pixels, not just the JS. Skeletons are fine. Decoded images on frame one are better.
  4. Test for instant, or it won't stay instant. Throttle the network in CI and assert the shell still mounts.
All posts Try Learn mode
© 2026 flags.games · About · Methodology · Terms · Privacy · Brand