Deploys and stale clients
Contract digests, skew detection, and client/stale: the compatibility window as a detected, owned state.
Every deploy opens a compatibility window: new server, old tabs. In most stacks the window is invisible — a stale client’s failures are indistinguishable from bugs (bad requests, decode failures), Sentry counts every deploy as an incident spike, and the “fix” is a user who happens to press reload. Closed unions make the window more acute, not less: a stale client cannot even decode an error tag added after it was built.
result-rpc makes the window a detected, owned state:
- The server stamps every response with a digest of its contract —
procedure paths, kinds, and every error tag with its policy
(
x-result-rpc-contract). A router and the contract it implements digest identically; nothing to configure. - The client compares the stamp to its own digest. The first mismatch emits
a
skewClientEvent — observability sees the drift before anything fails. - When a request fails with a contract-shaped tag (
server/bad-request,client/decode-failure,client/protocol-violation,client/http-failure) while the digests differ, the failure is reclassified asclient/stale, carrying the original tag. Matching digests change nothing — a real defect stays a defect, and successful calls are never touched.
And client/stale has a built-in owner: the boundary’s StaleShell claims
it, holds the affected operations, and reacts — by default with a page
reload, because the reload fetches the current client, which is the fix.
Override it to taste:
const { BoundaryProvider } = boundaryShells({
onStale: () => toast("A new version is available", { action: reload }),
})
The automatic digest reads what codecs expose, so a field-level change inside an object codec does not flip it on its own (the failure it causes usually travels with a visible change — but not always). For per-deploy exactness, stamp both sides with the build:
createFetchHandler({ router, contractVersion: BUILD_SHA, ... })
createClient({ contract, contractVersion: BUILD_SHA, ... })
Detection is failure-gated, so the coarser stamp is safe: matching successful calls are never reclassified.
Deploys then stay boring the same way database migrations do: expand, then contract. Ship additive changes first (new procedures, new tags — old clients never call what they don’t know about), and make removals and reshapes a later deploy, after the previous client generation has drained. When a stale tab does cross the window, it reloads once instead of mis-reporting a bug. This is the same discipline onwardpg enforces for the database tier — expand while old code is live, contract after it drains — applied one level up, between the server and the browsers it left behind.