-
-
Notifications
You must be signed in to change notification settings - Fork 1
SSR Cancellation
Cooperative cancellation for server-side rendering: thread
AbortSignalfrom the request throughcloneRouter→ loaders →fetchso dropped requests stop wasting work.
// 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 routerA 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'sfetch(url)calls run to completion against the upstream, and the server only discovers the dead connection onres.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.
| 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:
- Wire the request signal as a router dependency via
cloneRouter. - Read it inside the loader factory via
getDep("abortSignal"). - Compose with
withTimeoutso the loader gets asignalthat aborts on the first of: deadline elapsed, request disconnect, or navigation cancellation.
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();
}
}@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 withabortSignal: request.signalalready injected as a dependency. -
signal— the sameAbortSignal(handy for piping intorenderToReadableStream({ signal })). -
dispose()— detaches thereq.on("close")listener (Node path) and callsrouter.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().
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:
- The deadline timer firing (
msms afterwithTimeoutwas called) — the race rejects withLoaderTimeout. -
upstreamSignalaborting (client disconnect) — the race rejects withupstreamSignal.reason(or a freshAbortErrorif.reasonis undefined). - 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.
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.
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.
-
@real-router/ssr-data-plugin — Per-route data loading and
withTimeout -
Streaming SSR —
defer()+injectDeferredScriptsfor progressive HTTP-flush -
SSR Hydration —
serializeRouterState+hydrateRouterround-trip - cloneRouter — Per-request router cloning with dependency injection
-
start — Router start method intercepted by
ssr-data-plugin
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
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