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

After Instant: Chunk Retries, Usable Markers, and the P95 Tail

A dead tap on /globe exposed what instant shells miss: one-shot dynamic imports, content that trails the shell, and medians that hide the tail. The fixes, with code.

September 4, 2026 · 7 min read

We shipped instant navigations: shells mount in under a frame, route code is prefetched before the tap. A week later, /globe greeted players with a frozen shell and one console line: TypeError: Failed to fetch dynamically imported module.

Fixing it took a state machine, a readiness signal, and budgets written for the slowest 10% of visits. This is the sequel to Making Every Navigation Instant, and it covers what the first round missed. The original push still comes from Vercel's v0 write-up: make the fast path fast, then prove it in CI.

One-shot imports are a single point of failure

Every deferred route started life as a small wrapper around import(). One promise, one chance:

apps/web/src/routes/globe/+page.svelte
let GlobePage = $state<Component | null>(null);

onMount(async () => {
  const module = await import("./GlobePageClient.svelte");
  GlobePage = module.default;
});

Two failure modes show up in production:

  • Deploy skew. Each deploy changes chunk hashes. A tab open across a deploy asks for a chunk the new deployment no longer serves.
  • Flaky radio. A dropped packet mid-fetch rejects the promise on cellular, no matter how fast the shell was.

import() has no retry. The promise rejects, nothing catches it, and the page sits there: chrome painted, content missing. That is exactly the broken feeling the instant work was supposed to remove.

A dead tap on /globe 0ms shell, then nothing
  1. Shell mounts

    0ms

    Title and skeleton paint in the same frame. So far, so good.

  2. import() fetches the chunk

    ~400ms

    GlobePageClient.svelte downloads over whatever connection the player has.

  3. The promise rejects

    then

    Deploy skew or a dropped packet. TypeError, uncaught.

  4. Dead page

    forever

    No retry, no error state. The player refreshes or leaves.

The deferred-route state machine

We replaced the bare promise with a small class, DeferredRouteModule. It owns the import for one route and tracks three states: loading, ready, error.

apps/web/src/lib/navigation/deferred-route-module.svelte.ts
export class DeferredRouteModule {
  component = $state<Component | null>(null);
  status = $state<"loading" | "ready" | "error">("loading");

  private attempt = 0;
  private generation = 0;

  async start(): Promise<void> {
    const generation = ++this.generation;
    this.status = "loading";
    try {
      const module = await this.loader();
      if (generation !== this.generation) return; // left the page
      this.component = module.default;
      this.status = "ready";
    } catch (error) {
      if (generation !== this.generation) return;
      if (this.attempt++ === 0) {
        setTimeout(() => void this.start(), 1500); // one quiet retry
        return;
      }
      this.status = "error";
      reportDeferredRouteError(this.route, error);
    }
  }
}

Three decisions carry the weight:

  1. One quiet retry. A first failure waits 1.5 seconds and tries again before anyone sees anything. Transient radio drops resolve here, and Sentry never hears about them.
  2. Cancellation by generation. Every start() bumps a counter. If the player navigates away mid-flight, the late resolution checks the counter and drops the result instead of writing to an unmounted page.
  3. A real error state. If the retry also fails, the shell swaps the skeleton for a short message and a Try again button, and reports to Sentry tagged with the route.

All ten deferred routes (Play, Lobby, Learn, Social, Globe, and five challenges) now mount through one wrapper:

apps/web/src/routes/globe/+page.svelte
<DeferredRouteContent
  route="/globe"
  title="Globe"
  subtitle="Spin the planet, name the flag"
/>
The same tap, hardened recovers in ~2s worst case
  1. Shell mounts

    < 1 frame

    The shell stays instant. Nothing about the fast path changed.

  2. Chunk import starts

    ~400ms

    Prefetch usually beat the tap, so the module is often already in memory.

  3. One quiet retry

    +1.5s if it fails

    Transient failures resolve before the player ever sees an error.

  4. Content mounts, marks itself usable

    then

    The page sets data-route-usable and the game is live.

Shell and usable are two different events

The first round of tests asserted that the shell mounts within 750ms on a 2-second network. That bar has a hole: a shell can mount in 8ms while the content arrives 2 seconds later, or never. The tests were passing on pages a player would call broken.

We added a second signal. When deferred content mounts, it stamps data-route-usable on its root. A small MutationObserver helper lets tests and telemetry wait for that attribute instead of the shell:

apps/web/src/lib/navigation/route-readiness.ts
// Stamped by the deferred content once it mounts
export function publishRouteUsable(el: HTMLElement, route: string) {
  el.setAttribute("data-route-usable", route);
}

// Tests and telemetry listen for the attribute
export function onRouteUsable(cb: (route: string) => void) {
  const observer = new MutationObserver(() => {
    const el = document.querySelector("[data-route-usable]");
    if (el) cb(el.getAttribute("data-route-usable")!);
  });
  observer.observe(document.body, { subtree: true, attributes: true });
  return () => observer.disconnect();
}

The Playwright instant() helper grew a usable flag. With it set, the test passes only when the content itself is ready inside the throttled window:

apps/web/e2e/smoke.instant-navigation.spec.ts
test("globe becomes usable on a 2s network", async ({ page }) => {
  await gotoAppRoute(page, "/");
  const link = await prepareLink(page, "/globe");

  await instant(page, () => link.click(), {
    destinationPath: "/globe",
    shell: page.getByRole("heading", { name: "Globe" }),
    usable: true, // also waits for main [data-route-usable]
  });
});

Route coverage went from a dozen hand-picked paths to 27: every challenge, leaderboard tabs, help and blog articles, legal pages, a flag detail page. The number matters less than the rule behind it: every unique page type gets a smoke assertion, because the interesting failures only ever happen on the page nobody tested.

Budgets for the slowest 10%

Lab tests measure one throttled laptop. Real players arrive on real phones, and the gap between the median visit and the 95th percentile is where dead taps live. So the targets are stated as tail budgets:

  • Warmed shell: P90 at or under 500ms, P95 at or under 750ms.
  • Usable content: P90 at or under 1s, P95 at or under 1.5s.
  • Deferred-route failure rate under 0.1% of mounts, tracked in Sentry.

Holding those numbers needs real-user telemetry, and CI alone cannot provide it. Web-vitals reporting plus a custom mark on data-route-usable gives a per-route P90/P95. The Playwright suite stays on as the regression tripwire.

The payload diet from the first round matters most here. At the 2-second mark after load, the site went from 250 requests to 177, from 220 scripts to 140, and from 2.9MB of decoded JavaScript to 2.0MB. A median visit barely notices. The tail feels every kilobyte.

Keep crawlers on the fast path

Deferred content carries one SEO risk: move everything meaningful behind a client-only import, and the prerendered HTML thins out into shells. Two rules keep that in check:

  • Static, indexable content (rules, articles, flag facts) stays in prerendered HTML. Deferred loading is for interactive games, never for text.
  • The shell and the error UI are client concerns. Server-rendered pages keep their full content with JavaScript disabled.

The checklist for any project

  1. Grep for onMount + import(), and for React.lazy. Every one-shot dynamic import is a page that can die on deploy day.
  2. Wrap each one in a state machine: loading, ready, error, one quiet retry, cancellation on unmount.
  3. Report final failures to your error tracker, tagged by route. Quiet retries are fine. Quiet deaths are not.
  4. Stamp a usable marker when real content mounts. Test and measure against it, and treat the shell as a separate, earlier event.
  5. Throttle the network in CI and assert usable. Cover every unique page type, happy paths included.
  6. Write budgets as P90/P95 and wire real-user telemetry. A median you control in a lab says little about a phone on a train.
  7. Keep indexable content in the server-rendered HTML.

Takeaways

  1. A fast shell with a dead import behind it is a broken page that happens to load quickly.
  2. import() needs a retry policy, a cancel path, and an error UI. One quiet retry absorbs most of it.
  3. Test for usable. The skeleton will pass its own test every time.
  4. State budgets at P90/P95. The tail is where the taps die.
All posts Try Learn mode
© 2026 flags.games · About · Methodology · Terms · Privacy · Brand