tRPC WebSocket Support: One Router, Two Protocols
Need real‑time data without duplicating logic? tRPC’s experimental WebSocket support lets you share a single router for REST and real‑time APIs. Follow a concrete example, see the trade‑offs, and learn how to deploy it safely.
01 Feb 2026, 20:05 UTC

Problem: Duplicate API Logic for Real‑Time Features
When you add live updates to an existing tRPC‑based backend, you often end up writing the same procedure twice: once for a normal HTTP query/mutation and once for a WebSocket subscription. That duplication breaks type safety, inflates the codebase, and makes authentication or rate‑limiting hard to keep in sync.
Thesis: tRPC’s WebSocket Support Lets One Router Serve Both Worlds
tRPC’s experimental trpc-ws package extends the router definition so that the same query, mutation, or subscription can be invoked over HTTP or WebSocket. The router, middleware, and context pipeline remain unchanged, so you get consistent auth, logging, and error handling without extra plumbing.
How the Transport Works
Under the hood, trpc-ws builds on a standard WebSocket server (e.g., ws or uWebSockets). The server hosts a single endpoint that accepts WebSocket upgrades, and the tRPC handler translates incoming frames into procedure calls. On the client side, @trpc/client offers createTRPCWSClient which hides the handshake and gives you the same query, mutation, and subscription API you already use with HTTP.
Server‑Side Setup
// server.ts
import { createTRPCRouter, publicProcedure } from "@trpc/server";
import { initTRPC } from "@trpc/server";
import { createWSServer } from "ws";
import { applyWSSHandler } from "trpc-ws";
const t = initTRPC.create();
const appRouter = t.router({
hello: publicProcedure.query(() => "Hello from tRPC!")
});
const server = createWSServer({ port: 4000 });
applyWSSHandler({
wss: server,
router: appRouter,
createContext: () => ({}),
onConnect: (conn) => console.log('WS connected', conn),
onDisconnect: (conn) => console.log('WS disconnected', conn)
});
console.log('WebSocket server listening on ws://localhost:4000');
Run the server with ts-node server.ts. The onConnect and onDisconnect hooks let you attach per‑socket state, such as a user ID after authentication.
Client‑Side Connection
import { createTRPCWSClient } from "@trpc/client";
const client = createTRPCWSClient({
url: 'ws://localhost:4000',
transformer: superjson // optional, keeps type‑safety
});
// Query over WebSocket
client.query('hello').then(console.log);
Because the client uses the same API, you can swap the transport in a single place if you later decide to expose a REST fallback.
Concrete Example: A Time Subscription
Below is a minimal router that exposes a time subscription which emits the current timestamp every second.
const appRouter = t.router({
hello: publicProcedure.query(() => "Hello!")
,
time: publicProcedure.subscription(() => {
return async function* () {
while (true) {
yield new Date().toISOString();
await new Promise((r) => setTimeout(r, 1000));
}
};
})
});
On the client:
client.subscription('time', {
onData: (time) => console.log('Server time:', time),
onError: (err) => console.error(err),
});
Running the server and opening a browser console will show a log line every second. The same router can also be used with createTRPCReact for a REST fallback, demonstrating the single‑router advantage.
Trade‑offs & Limitations
- Experimental Status: The WebSocket API is still marked experimental. Future releases may rename the package or change the hook signatures.
- Serverless Deployment: Platforms like Vercel or Netlify require a dedicated WebSocket endpoint (e.g., API Gateway or Cloudflare Workers). That adds cost and configuration overhead.
- Event‑Loop Blocking: Subscriptions run in the same Node.js process. Heavy or blocking logic inside a subscription can stall all sockets. Use
async/await, worker threads, or separate micro‑services for CPU‑intensive tasks. - Debugging: Standard tRPC error handling is tied to HTTP status codes. When debugging over WebSocket you’ll need browser devtools or external tools like Wireshark to inspect frames.
Checking the Result
After starting the server, open a browser console and run the client code. You should see:
- Console logs for
WS connectedandWS disconnected. - The
helloquery returningHello!. - Every second a new timestamp logged via the
timesubscription.
If any step fails, double‑check that the WebSocket server is listening on the correct port and that the client URL matches exactly.
Actionable Closing
1. Add trpc-ws to your project: npm i trpc-ws @trpc/server @trpc/client.
2. Create a shared router with queries, mutations, and subscriptions.
3. Spin up a lightweight ws server and apply applyWSSHandler.
4. Connect from the client with createTRPCWSClient.
5. For production, choose a WebSocket‑capable host (e.g., AWS API Gateway WebSocket or Cloudflare Workers) and configure health checks.
By keeping all API logic in a single router, you preserve type safety, avoid duplication, and can deliver real‑time features with minimal friction. Just remember the experimental flag and plan for the event‑loop considerations that come with stateful connections.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.