This episode breaks down the tRPC transport layer: HTTP batching with httpBatchLink, standard HTTP with httpLink, and WebSocket with wsLink. You will also learn CORS configuration, request headers, and setting up server subscriptions in Express.

All of tRPC's type-safety eventually passes through the network. Episode 10 breaks down transport — the layer that determines how requests are sent. You will understand the differences between httpLink, httpBatchLink, and wsLink, when to use each, as well as supporting configuration such as CORS, request headers, and proxy.
Choosing the right transport has a direct impact on latency and user experience, so understand the characteristics of each link well.
httpLink sends every operation as a separate HTTP request. Suitable for mutations and applications with few queries:
import { createTRPCClient, httpLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [httpLink({ url: "http://localhost:3000/trpc" })],
});
const user = await client.user.byId.query({ id: 1 });httpLink({ url }) sends the operation with a POST method to the specified URL. Because one operation equals one request, this model is simple and suitable when batching offers little benefit.
httpBatchLink combines many operations that happen at the same time into one request. Queries called by several components at once will be sent together:
import { httpBatchLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({ url: "http://localhost:3000/trpc" }),
],
});
const [user, posts] = await Promise.all([
client.user.byId.query({ id: 1 }),
client.post.list.query({ authorId: 1 }),
]);With httpBatchLink, the two queries above run in a single HTTP round-trip — the server processes both and returns the results in one response. This is a huge latency saving in applications with many queries per page. An important note: mutations must not be batched. If necessary, use splitLink to route mutations to httpLink.
Subscriptions require a persistent connection. wsLink maintains a WebSocket connection and streams real-time events:
import { wsLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
wsLink({
url: "ws://localhost:3000/trpc",
}),
],
});
const unsub = client.clock.subscribe(undefined, {
onData: (data) => console.log("Waktu baru:", data.waktu),
});client.clock.subscribe(undefined, { onData }) opens a stream and calls onData every time the server sends an event. Because the WebSocket stays open, this is ideal for notifications, chat, and price updates.
Use splitLink so a single client handles all three — exactly the pattern from episode 6:
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ url: "ws://localhost:3000" }),
false: httpBatchLink({ url: "http://localhost:3000/trpc" }),
}),
],Subscriptions through WebSocket, while queries and mutations through HTTP batch — the most common combination in production.
On the server side, WebSocket is served alongside HTTP. Use wsServer (called applyWSSHandler in tRPC v10), which imports the same router:
import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { wsServer } from "@trpc/server/adapters/ws";
import { appRouter } from "./routers";
const { server } = createHTTPServer({
router: appRouter,
createContext: () => ({}),
});
wsServer({
wss: new WebSocketServer({ server }),
router: appRouter,
createContext: () => ({}),
});
server.listen(3000);The same router serves both HTTP and WebSocket at once — no duplication of definitions. WebSocketServer is imported from the ws package, a dependency you need to install:
npm install ws
npm install --save-dev @types/wsWhen the client and server are on different domains, CORS must be configured. In Express, simply mount the middleware before tRPC:
app.use(cors({ origin: ["http://localhost:5173"], credentials: true }));If authentication uses cookies, you must enable credentials: true and specify the origin explicitly — wildcards are not supported for cookies.
The client can add headers via the headers option on a link, or use op.context for dynamic values:
import { httpBatchLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
headers: () => ({
Authorization: `Bearer ${getToken()}`,
}),
}),
],
});headers: () => ({ Authorization }) adds a token to every request. On the server side, the token is read from ctx.req.headers in the authentication middleware — the flow will be completed in episode 11.
tRPC runs normally behind a reverse proxy like Nginx or Traefik as long as the path is routed to the application. What to watch out for: WebSocket requires the upgrade header to be forwarded by the proxy, and CORS is still configured in the application because the proxy preserves headers.
Warning
Do not put auth tokens in the URL query. Use the Authorization header. Query strings are commonly recorded in server and proxy logs — the correct header prevents tokens leaking through logs.
Episode 10 completes the transport layer: httpLink for single requests, httpBatchLink for round-trip savings, wsLink for real-time, as well as CORS, headers, and proxy configuration so the server is ready to be accessed from anywhere.
Key takeaways:
httpLink is one request per operation; httpBatchLink combines queries.splitLink.wsLink is required for real-time subscriptions.wsServer (v10: applyWSSHandler) serves WebSocket with the same router.credentials: true is required when the client is on a different domain.In the next episode, episode 11, we will discuss authentication & authorization in tRPC — authentication middleware with tokens, sessions, or cookies, per-procedure authorization and RBAC, as well as integration with NextAuth, Clerk, or a custom auth provider.