Choosing a tRPC Client Transport: Batch, Stream, WebSocket, or SSE
A decision guide to tRPC client transports: when httpBatchLink is enough, when streaming or SSE subscriptions earn their place, and how to route both through one splitLink client.
17 Mar 2026, 21:52 UTC

Your tRPC router compiles, procedures are typed end to end, and the first useQuery returns data. One decision is easy to defer and awkward to reverse later: which client link carries your traffic. A link is tRPC's client-side transport — it decides whether each call gets its own HTTP request, calls coalesce into batches, results stream back individually, or a live connection stays open.
The short version for a tRPC v11 React or Next.js app: httpBatchLink for queries and mutations, httpSubscriptionLink for live updates, and wsLink or httpBatchStreamLink only when a specific constraint forces them. The rest of this guide is the reasoning, the trade-offs, and a network-tab check to confirm the choice in your own environment.
What constrains the transport choice
Three constraints do most of the deciding:
- Hosting model. Per-request serverless functions (AWS Lambda, Vercel or Netlify functions) can't hold WebSocket connections open cheaply. A long-lived server can.
- Latency sensitivity. If one render fires five queries and one is slow, should the other four wait for it?
- Live updates. Do any screens need server-pushed data, and how often?
This guide assumes tRPC v11.x with @trpc/react-query. Link availability differs by major version — v10 clients need wsLink for subscriptions, for example — so confirm option names against the docs for your pinned version.
The five links at a glance
| Link | Wire behavior | Fits when | Main cost or requirement |
|---|---|---|---|
httpLink | One HTTP request per call | Low call volume; simplest debugging | No batching; most per-call overhead |
httpBatchLink | Calls issued in the same tick merge into one request | Default for React/Next queries and mutations | Batch resolves together (head-of-line blocking) |
httpBatchStreamLink | Batched request, but each result streams back as it completes | Mixed fast and slow calls in one render | Host, CDN, and proxies must not buffer streams |
wsLink | Persistent bidirectional WebSocket | Pre-v11 subscriptions; heavy two-way traffic | Long-lived server; poor fit for serverless |
httpSubscriptionLink | Subscriptions over server-sent events on plain HTTP | v11 live updates on serverless-friendly hosting | Experimental flag in some releases; check your version |
Head-of-line blocking: the batch trade-off
httpBatchLink merges calls issued in the same tick into a single HTTP request, cutting connection and header overhead. The cost is that the batch resolves together: a 20 ms sidebar count sharing a batch with a 2 s report query waits for the report. That is head-of-line blocking — one slow item delays everything queued behind it.
Two facts make the decision easier. First, batching is transport-only: React Query cache entries remain per procedure, so staleTime, invalidation, and retries behave identically under httpLink and httpBatchLink. You are changing wire efficiency, not data-fetching semantics. Second, maxURLLength splits oversized GET-based batches before they hit URL length limits, so a page that fires many queries doesn't force you back to httpLink.
Streaming keeps the batch but unblocks fast calls
httpBatchStreamLink keeps the single batched request but streams each procedure's result back as it completes, so fast calls render while the slow one is still running. The requirement is end-to-end streaming support: some proxies, CDNs, and serverless platforms buffer streamed responses, which silently restores the blocking you were trying to remove. If you adopt it, validate on a production-like path, not just localhost.
Live updates: WebSocket or server-sent events
Before v11, subscriptions had one transport: wsLink, a persistent bidirectional WebSocket backed by the ws adapter in @trpc/server. It works, but it means a long-lived server, connection-lifecycle handling (reconnects, re-authentication on reconnect), and real cost on serverless platforms unless they offer WebSocket support — API Gateway WebSockets, for instance — which adds complexity of its own.
v11 added httpSubscriptionLink: subscriptions ride plain HTTP using server-sent events (SSE), a one-way streaming response the browser consumes as it arrives. Queries and mutations stay on httpBatchLink while live data flows over SSE, which suits serverless-friendly deployments. Two caveats: the tRPC docs have flagged SSE subscriptions as experimental in some releases, so check the changelog for your pinned version before building on it, and your server adapter must support streaming responses — the fetch adapter used with Next.js App Router is the common case; verify yours.
Route by operation type with splitLink
splitLink is the documented way to mix transports in one client: each operation is tested against a condition and routed down the matching branch. Subscriptions go to the subscription link; everything else goes to the batch link. No procedure definitions change — only the client link chain.
// src/trpc/client.tsx — client setup module; runs in the browser and during SSR
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink, httpSubscriptionLink, splitLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app'; // type-only import from server code
export const trpc = createTRPCReact<AppRouter>();
const TRPC_URL = '/api/trpc'; // placeholder: your tRPC endpoint path or absolute URL
export const trpcClient = trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: [httpSubscriptionLink({ url: TRPC_URL })],
false: [httpBatchLink({ url: TRPC_URL, maxURLLength: 2083 })],
}),
],
});
Replace TRPC_URL with your endpoint, and keep the AppRouter import type-only so server code never bundles into the client. The 2083 value is a common conservative URL cap — adjust it to your stack. Expected behavior: queries and mutations produce one coalesced request per tick; a subscription opens a streaming response that stays open. If you need to back out, reverting this file's links restores the previous transport — nothing else in the app changes.
Skip HTTP for tests and internal jobs
Server-to-server calls and unit tests shouldn't traverse the link chain at all. Use createCallerFactory (v11) or router.createCaller (v10) to invoke procedures directly with a fabricated context:
// server-side: a test file or internal job — no HTTP involved
const caller = createCallerFactory(appRouter)(testContext);
const posts = await caller.post.list();
Reserve links for real client traffic; callers keep business logic testable independent of the transport.
Verify the choice in your environment
- Count the requests. Against your local dev server, render a component that fires two or three
trpc.useQuerycalls at once. In the browser network tab,httpBatchLinkshould show one request containing all inputs;httpLinkshows one request per call. - Prove the blocking behavior. Temporarily delay one query handler server-side (a short sleep is enough). Under
httpBatchLink, the other results in that batch should resolve only when the slow one finishes; underhttpBatchStreamLink, they should render first. Remove the delay afterwards. - Watch a subscription. With the
splitLinkconfig above, the network tab should show a streaming (event-stream) request for the subscription while queries continue as batched requests. - Repeat on staging. Proxies and CDNs can buffer streams, so steps 2 and 3 can pass locally and fail in production. Re-run them on a deployment that mirrors your production path.
- Check your pinned version. Confirm
httpSubscriptionLinkavailability and any experimental warning in the changelog and docs for the version you've locked.
Limitations to weigh
- Version drift. Option names, defaults, and experimental flags move between releases; this guide pins v11.x and flags the spots to re-check.
- Buffering. Streamed responses (
httpBatchStreamLink, SSE) can be buffered by intermediaries; production-like testing is the only reliable check. - Batch size. Don't let batches grow unbounded — very large batches hit body or URL limits and complicate per-call retries.
maxURLLengthhelps for GET-based batches. - Serverless WebSockets.
wsLinkneeds platform-level WebSocket support and adds cost and operational complexity. - No semantic change. Batching won't alter React Query cache behavior — if you're debugging cache semantics, the transport is the wrong suspect.
Default to httpBatchLink, add httpSubscriptionLink when live data appears, and let the network tab — run against an environment that looks like production — settle the streaming question.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.