Skip to content

SSR Cancellation

olegivanov edited this page May 16, 2026 · 1 revision

SSR Cancellation

Cooperative cancellation for server-side rendering: thread AbortSignal from the request through cloneRouter → loaders → fetch so dropped requests stop wasting work.

TL;DR

// Web runtime — request already carries `request.signal`:
const router = cloneRouter(base, { abortSignal: request.signal });
router.usePlugin(ssrDataPluginFactory(loaders));
const state = await router.start(request.url);

// Or use the helper that wires the signal automatically:
import { createRequestScope } from "@real-router/core/utils";
const scope = createRequestScope(request, baseRouter, { db });
scope.router.usePlugin(ssrDataPluginFactory(loaders));
const state = await scope.router.start(request.url);
await scope.dispose(); // unhooks the close listener, disposes the cloned router

Why cancellation matters in SSR

A server render that takes longer than the client's patience is wasted work:

  • Browsers abort the underlying TCP connection on tab close, page navigation, or AbortController.abort().
  • Slow upstream APIs (DB, search index, third-party services) keep occupying server threads, file descriptors, and downstream connection pools even after the client gives up.
  • Without a propagated AbortSignal, the loader's fetch(url) calls run to completion against the upstream, and the server only discovers the dead connection on res.write().

Threading a single AbortSignal from the request all the way down to the loader's I/O fixes this — every layer cancels in lock-step.

Three signal sources

Source Where it lives When it aborts
Request-scoped (abortSignal dep) cloneRouter(base, { abortSignal }) Client disconnect / explicit cancel
Per-navigation (router internal) state.transition.signal (sync guards) A newer router.navigate() supersedes the older
Per-loader deadline (withTimeout) ctx.signal inside the loader Deadline elapsed OR composed upstream aborted

Use them together. The recommended pattern is:

  1. Wire the request signal as a router dependency via cloneRouter.
  2. Read it inside the loader factory via getDep("abortSignal").
  3. Compose with withTimeout so the loader gets a signal that aborts on the first of: deadline elapsed, request disconnect, or navigation cancellation.

Manual wiring with cloneRouter

For platforms where the request object isn't directly compatible with createRequestScope, wire the signal yourself:

import { createRouter } from "@real-router/core";
import { cloneRouter } from "@real-router/core/api";
import {
  ssrDataPluginFactory,
  type DataLoaderFactoryMap,
} from "@real-router/ssr-data-plugin";
import { withTimeout } from "@real-router/ssr-data-plugin/errors";

// One base router at module load.
const baseRouter = createRouter(routes, { defaultRoute: "home" });

const loaders: DataLoaderFactoryMap = {
  "users.profile": (_router, getDep) => async (params) => {
    // Pull the request-scoped signal off the dependency map.
    const upstreamSignal = (
      getDep as unknown as (k: string) => AbortSignal | undefined
    )("abortSignal");

    return withTimeout(
      "users.profile",
      250, // ms deadline
      async ({ signal }) => {
        // `signal` aborts on the first of:
        //   1. the 250 ms deadline,
        //   2. `upstreamSignal` (client disconnect),
        //   3. router-internal navigation cancellation.
        const response = await fetch(`/api/user/${params.id}`, { signal });

        return response.json();
      },
      { upstreamSignal },
    );
  },
};

// Per-request handler (Express-style):
export async function render(req, res) {
  const controller = new AbortController();

  req.on("close", () => {
    controller.abort();
  });

  const router = cloneRouter(baseRouter, { abortSignal: controller.signal });
  router.usePlugin(ssrDataPluginFactory(loaders));

  try {
    const state = await router.start(req.url);

    res.write(renderToString(<App router={router} state={state} />));
    res.end();
  } finally {
    router.dispose();
  }
}

Recommended: createRequestScope

@real-router/core/utils ships a helper that handles the boilerplate:

import { createRequestScope } from "@real-router/core/utils";
import { ssrDataPluginFactory } from "@real-router/ssr-data-plugin";

// Web runtime (Fetch API — `request.signal` is forwarded directly):
async function handler(request) {
  const scope = createRequestScope(request, baseRouter, { db });

  try {
    scope.router.usePlugin(ssrDataPluginFactory(loaders));
    const state = await scope.router.start(request.url);

    return new Response(renderToString(<App />), {
      headers: { "Content-Type": "text/html" },
    });
  } finally {
    await scope.dispose();
  }
}

// Node.js (IncomingMessage — `createRequestScope` wires `req.on("close")`):
export async function render(req, res) {
  const scope = createRequestScope(req, baseRouter, { currentUser });

  try {
    scope.router.usePlugin(ssrDataPluginFactory(loaders));
    const state = await scope.router.start(req.url);

    res.end(renderToString(<App />));
  } finally {
    await scope.dispose();
  }
}

createRequestScope returns { router, signal, dispose, [Symbol.asyncDispose] }:

  • router — cloned router with abortSignal: request.signal already injected as a dependency.
  • signal — the same AbortSignal (handy for piping into renderToReadableStream({ signal })).
  • dispose() — detaches the req.on("close") listener (Node path) and calls router.dispose().

On Node 24+, Bun 1.0.23+, Deno 1.37+, and Chrome 127+ / Firefox 141+, the await using form works:

async function render(request) {
  await using scope = createRequestScope(request, baseRouter, { db });
  scope.router.usePlugin(ssrDataPluginFactory(loaders));
  return await renderShell(scope.router, request.url);
}

On Node 22 LTS the Symbol.asyncDispose global is unavailable — stick with try/finally + await scope.dispose().

How withTimeout composes signals

withTimeout(routeName, ms, loader, { upstreamSignal }) builds a composed AbortSignal using AbortSignal.any([upstreamSignal, internalDeadlineSignal]) (Node 20.3+, Bun, Deno, all evergreen browsers). Inside the loader, ctx.signal aborts on the first of:

  1. The deadline timer firing (ms ms after withTimeout was called) — the race rejects with LoaderTimeout.
  2. upstreamSignal aborting (client disconnect) — the race rejects with upstreamSignal.reason (or a fresh AbortError if .reason is undefined).
  3. The loader resolving normally — the timer is cleared via .finally(), no leak.

Pre-abort short-circuit. If upstreamSignal is already aborted at the call site, withTimeout rejects synchronously without invoking the loader and without starting the timer. So a request that disconnects before the loader runs costs zero work.

Cancellation is cooperative. If the loader's I/O doesn't propagate signal, the loader runs to completion in the background — the race result is unaffected, but resources aren't freed early. Always thread signal into fetch, the DB driver, and any other awaitable I/O.

Robust loaders check signal.aborted upfront

A signal aborted before addEventListener("abort", ...) does NOT auto-fire the listener — the event has already passed. Bake the precheck into every cancellation-aware loader:

return async (params, ctx) => {
  await new Promise<void>((resolve, reject) => {
    const t = setTimeout(resolve, 25);

    const onAbort = (): void => {
      clearTimeout(t);
      reject(new DOMException("aborted", "AbortError"));
    };

    // Pre-aborted? Fire the handler synchronously.
    if (ctx?.signal.aborted) {
      onAbort();
      return;
    }

    ctx?.signal.addEventListener("abort", onAbort, { once: true });
  });

  // ... actual work ...
};

The pattern is also documented in packages/ssr-data-plugin/CLAUDE.md.

Mapping LoaderTimeout to HTTP 504

try {
  const state = await router.start(url);
  return renderHtml(state);
} catch (error) {
  if (error?.code === "LOADER_TIMEOUT") return res.status(504).send("Gateway Timeout");
  if (error?.code === "LOADER_NOT_FOUND") return res.status(404).send("Not Found");
  if (error?.code === "LOADER_REDIRECT") return res.redirect(error.status, error.target);
  throw error;
}

The structural error.code discriminator avoids instanceof coupling across realms / bundle boundaries — match by .code and propagate the right HTTP status.

See Also

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

SSR-feature subpath — @real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.

  • Lazy (Svelte only — dynamic component import)
  • Await — read deferred SSR data by key
  • Streamed — Suspense-style boundary
  • ClientOnly — server fallback → client children swap
  • ServerOnly — server children, removed after hydration
  • HttpStatusCode — render-time HTTP status declaration
  • HttpStatusProvider — provides sink to descendant <HttpStatusCode>
  • useDeferred — read deferred Promise by key

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally