This episode strengthens the resilience of tRPC applications: retry with exponential backoff and error boundaries on the client, data fallback strategies and downtime handling, and graceful shutdown for servers in a production environment.

Networks are unreliable — servers restart, connections drop, and databases are occasionally slow. Resilience is the ability of an application to keep working when failures occur. Episode 15 covers resilience patterns on the client: retry with exponential backoff and error boundaries, data fallback strategies, as well as graceful shutdown on the server.
The goal isn't to eliminate failures — that's impossible — but to make sure a failure doesn't ruin the entire experience.
React Query retries failed queries automatically. You can configure how many times and the delay:
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error.data?.code === "UNAUTHORIZED") return false;
return failureCount < 3;
},
},
},
});retry receives the failure count and the error. In the example, requests that fail because of UNAUTHORIZED are not retried — there's no point repeating an authentication problem — while other failures are retried up to three times.
React Query adds retry delays with exponential growth by default. If you need full control, set retryDelay:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 10_000),
},
},
});retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10_000) applies a 1 second, 2 second, 4 second delay with an upper cap of 10 seconds — a standard exponential backoff that doesn't overload the server while it's having trouble.
Unlike queries, mutations change data. Retrying a mutation that actually already succeeded on the server can produce duplicate data. Set retry on mutations to a low value, or use an idempotency key when the provider supports it.
Error boundaries catch render failures in child components. For data from tRPC, pair them with a fallback UI:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <p>Terjadi kesalahan. Silakan muat ulang.</p>;
}
return this.props.children;
}
}Wrap the parts of the page that depend on network data with ErrorBoundary, so a single failing component doesn't bring down the whole page.
When the server is down, show data that is still available instead of an empty screen:
const { data, error } = trpc.post.list.useQuery(undefined, {
retry: 2,
staleTime: 60_000,
placeholderData: () => dataPostSebelumnya,
});
if (error && !data) {
return (
<div>
<p>Server sedang gangguan. Menampilkan data terakhir yang tersimpan.</p>
<CacheTerakhir />
</div>
);
}The pattern above uses the React Query cache as a fallback: as long as data exists, the page keeps showing content even if the latest request failed. Persist the cache to localStorage with @tanstack/query-persist-client if you want the fallback to survive across sessions.
To notify users early, monitor repeated failures through onError:
const { data } = trpc.health.check.useQuery(undefined, {
refetchInterval: 30_000,
onError: () => setServerDown(true),
onSuccess: () => setServerDown(false),
});refetchInterval: 30_000 checks the server's health every 30 seconds. The serverDown status can show an "Offline mode" banner to the user.
When the server is shut down, in-flight requests must be given time to finish, not cut off forcibly. Graceful shutdown closes new listeners, waits for active requests to complete, then closes connections:
const server = createHTTPServer({
router: appRouter,
createContext: () => ({}),
}).listen(3000);
const shutdown = async (signal: string) => {
console.log(`Menerima ${signal}, memulai shutdown...`);
server.close(async () => {
await tutupDatabase();
console.log("Shutdown bersih selesai");
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));server.close stops accepting new connections and waits for in-flight requests to finish. After that, the database is closed cleanly. setTimeout(...).unref() is a safety net: if the process hangs for more than 10 seconds, force exit so the orchestrator doesn't hang.
WebSockets must also be closed during shutdown — close the ws server first, send a closing signal to connected clients, then close the HTTP server. This prevents clients from hanging while waiting for events that will never arrive.
Warning
Don't call process.exit without closing resources. In Kubernetes and containers, a process that exits forcibly leaves database and WebSocket connections broken abruptly — which can poison the connection pool of the next instance.
Episode 15 strengthens the resilience of tRPC applications: smart retry with exponential backoff on the client, error boundaries and data fallback to keep the UI working, and graceful shutdown so the server stops cleanly in production.
Key takeaways:
UNAUTHORIZED errors.In the next episode, episode 16, we will discuss tRPC in Next.js, Remix, and Serverless — integration with the Next.js App Router and pages router, usage in Remix, Astro, or other Node.js frameworks, as well as serverless deployment on Vercel, Cloudflare Pages, and AWS Lambda.