tRPC Source Code Architecture: The Internals
I set out to understand the internal workings of typescript Remote Procedure Call so here is my research. Welcome to the deep dive into how tRPC v11 actually works under the hood, the builder pattern, proxy magic, middleware resolution, link chains, and the type-level programming that makes end-to-end type safety possible without code generation.
Table of Contents
- Repository Structure
- Package Layout
- The initTRPC Builder
- The Procedure Builder
- Middleware Resolution Algorithm
- The Router: Building and Merging
- The Proxy Magic
- The Link Chain
- The DataLoader and Batching
- The Observable Implementation
- The RPC Protocol
- The Parser System
- Data Transformers
- HTTP Adapters and Request Dispatch
- SSE Stream Producer
- Error Handling
- Type Inference Pipeline
- Complete Request/Response Lifecycle
- Key Algorithms
- Source Code
1. Repository Structure
Before you can understand how tRPC works, you need to know where the code lives. The project is a monorepo, many packages in one repository, because tRPC ships separately for the server, the client, React bindings, and Next.js integration. This separation is deliberate: a vanilla Node.js server doesn’t need React in its bundle, and a browser client doesn’t need server-side adapters. The most important thing to understand is that the “core” implementation lives in a directory literally named unstable-core-do-not-import; the name itself is a guardrail, discouraging anyone from reaching into internals when a public API exists for the same purpose.
The tRPC monorepo is managed with pnpm workspaces and Turborepo. Understanding the layout is the first step to navigating the codebase.
Hire Runastartup to build, optimize or scale your software!
trpc/
├── packages/
│ ├── server/ # The heart: runtime, types, adapters
│ ├── client/ # Framework-agnostic tRPC client
│ ├── react-query/ # Classic React bindings
│ ├── tanstack-react-query/ # New React bindings (recommended)
│ ├── next/ # Next.js helpers (Pages Router)
│ ├── openapi/ # OpenAPI generator
│ ├── tests/ # Cross-package integration tests
│ └── upgrade/ # Codemod CLI for migrations
├── examples/ # Official examples
├── www/ # Documentation site (tRPC.io)
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── lerna.jsonKey Insight: The unstable-core-do-not-import Directory
In v11, the core implementation was moved from packages/server/src/core/ to packages/server/src/unstable-core-do-not-import/. The name is intentional,it discourages consumers from importing internal modules directly. All public exports flow through @trpc/server, @trpc/server/http, and @trpc/server/rpc.
graph TD
subgraph "Public API Surface"
A["@trpc/server"]
B["@trpc/server/http"]
C["@trpc/server/rpc"]
end
subgraph "Internal Core"
D["unstable-core-do-not-import/"]
end
subgraph "Adapters"
E["adapters/fetch/"]
F["adapters/express"]
G["adapters/fastify/"]
H["adapters/standalone"]
end
A --> D
A --> E
A --> F
A --> G
A --> H
B --> D
C --> D
style D fill:#f9f,stroke:#333,stroke-width:2px2. Package Layout
Every file in tRPC has a specific job, and the directory names tell you what that job is. The server package holds the runtime engine, the HTTP adapters, the observable implementation, and the core that ties them together. The client package holds the proxy, the link chain, and the batching engine. When you understand the layout, you can jump directly to the file that implements any feature without searching. The key insight is that adapters are thin wrappers;they translate framework-specific request objects into a common format, then delegate to the same core dispatch function regardless of whether you’re using Express, Fastify, or Next.js.
packages/server/src/ The Server Package
src/
├── @trpc/server/ # Public entry point (re-exports)
│ ├── index.ts
│ ├── http.ts
│ └── rpc.ts
├── adapters/ # HTTP framework adapters
│ ├── fetch/ # Fetch API (Next.js App Router, Remix, etc.)
│ ├── fastify/
│ ├── aws-lambda/
│ ├── next-app-dir/
│ ├── node-http/
│ ├── express.ts
│ ├── next.ts # Next.js Pages Router
│ ├── ws.ts # WebSocket adapter
│ ├── standalone.ts # Vanilla Node.js
│ └── wsEncoder.ts
├── observable/ # Zero-dependency Observable impl
│ ├── observable.ts
│ ├── operators.ts
│ └── types.ts
├── unstable-core-do-not-import/ # THE ACTUAL CORE
│ ├── initTRPC.ts
│ ├── procedureBuilder.ts
│ ├── procedure.ts
│ ├── middleware.ts
│ ├── router.ts
│ ├── rootConfig.ts
│ ├── transformer.ts
│ ├── parser.ts
│ ├── createProxy.ts # The ProxyHandler magic
│ ├── error/
│ │ ├── TRPCError.ts
│ │ ├── getErrorShape.ts
│ │ └── formatter.ts
│ ├── http/
│ │ ├── resolveResponse.ts # Master HTTP dispatch function
│ │ ├── contentType.ts # Content-type negotiation
│ │ └── getHTTPStatusCode.ts
│ ├── rpc/
│ │ ├── codes.ts # Error codes
│ │ ├── envelopes.ts # JSON-RPC 2.0 envelopes
│ │ └── parseTRPCMessage.ts
│ ├── stream/
│ │ ├── sse.ts # SSE stream producer
│ │ ├── jsonl.ts # JSON Lines streaming
│ │ └── tracked.ts # Tracked envelopes for SSE IDs
│ ├── clientish/ # Server-side helpers mimicking client types
│ └── vendor/ # Vendored libs (standard-schema-v1, etc.)
└── vendor/ # More vendored librariespackages/client/src/ The Client Package
src/
├── createTRPCClient.ts # Proxy decoration (the client you use)
├── createTRPCUntypedClient.ts
├── TRPCClientError.ts
├── internals/
│ ├── TRPCUntypedClient.ts # Runtime layer
│ └── dataLoader.ts # Batching engine
└── links/
├── types.ts # OperationLink interface
├── internals/
│ └── createChain.ts # Link chain execution
├── httpLink.ts
├── httpBatchLink.ts
├── httpBatchStreamLink.ts
├── httpSubscriptionLink.ts
├── loggerLink.ts
├── retryLink.ts
├── splitLink.ts
└── wsLink/
└── wsLink.ts3. The initTRPC Builder
Every tRPC server begins with a single call: initTRPC.create(). This is where global configuration; context type, error formatter, data transformer, SSE settings; gets baked into the framework instance that all your routers and procedures derive from. The cleverest part of the implementation is how it carries type information at the compile level while keeping runtime footprint at zero. A field called $types is set to null as any at runtime, but TypeScript treats it as a rich object carrying your context, metadata, and error shape types through every downstream operation. This pattern phantom types is the foundation of tRPC’s entire type-safety story, and understanding it unlocks every other concept in the codebase.
🔗 Source: packages/server/src/unstable-core-do-not-import/initTRPC.ts
The entry point to tRPC is a single instance of TRPCBuilder:
export const initTRPC = new TRPCBuilder();The Type-Level Builder Pattern
TRPCBuilder<TContext, TMeta> uses generics to accumulate configuration at compile time. Each method returns a new TRPCBuilder with refined type parameters:
flowchart LR
A["initTRPC<br/>(no context)"] -->|".context<Session>()"| B["TRPCBuilder<Session>"]
B -->|".meta<AppMeta>()"| C["TRPCBuilder<Session, AppMeta>"]
C -->|".create(config)"| D["TRPCRootObject<br/>(the 't' instance)"]
style A fill:#e0f0ff,stroke:#333
style D fill:#d4edda,stroke:#333,stroke-width:2pxclass TRPCBuilder<TContext, TMeta> {
context<TNewContext>() {
return new TRPCBuilder<TNewContext, TMeta>();
}
meta<TNewMeta>() {
return new TRPCBuilder<TContext, TNewMeta>();
}
create<TOptions>(opts?: TOptions): TRPCRootObject {
const config: RootConfig = {
...opts,
transformer: getDataTransformer(opts?.transformer ?? defaultTransformer),
isDev: opts?.isDev ?? globalThis.process?.env?.NODE_ENV !== 'production',
errorFormatter: opts?.errorFormatter ?? defaultFormatter,
isServer: opts?.isServer ?? isServerDefault,
$types: null as any, // ← PHANTOM TYPE FIELD
};
return {
procedure: createBuilder(),
middleware: createMiddlewareFactory(),
router: createRouterFactory(config),
mergeRouters,
createCallerFactory,
_config: config,
};
}
}The Phantom Types Pattern
The $types: null as any line is the most important detail in the entire codebase. At runtime, $types is null. But at compile time, it carries the full type signature of context, meta, error shape, and transformer types. This is how tRPC threads type information through runtime values without affecting the runtime.
graph TB
subgraph "Compile Time"
CT["$types: RootTypes<br>{ ctx: Session, meta: AppMeta, errorShape: ..., transformer: ... }"]
end
subgraph "Runtime"
RT["$types: null"]
end
CONFIG["config object"] --> CT
CONFIG --> RT
style CT fill:#d4edda,stroke:#333
style RT fill:#f8d7da,stroke:#333The create() method returns six members:
| Member | Purpose |
|---|---|
t.procedure | Creates a fresh ProcedureBuilder |
t.middleware | Creates middleware via factory |
t.router | Creates a router from a record of procedures |
t.mergeRouters | Merges multiple routers |
t.createCallerFactory | Creates server-side callers for testing |
t._config | The resolved RootConfig (rarely used directly) |
4. The Procedure Builder
A procedure is the atomic unit of a tRPC API, one endpoint that accepts input, runs through middleware, and returns data. The builder pattern used here should feel familiar if you’ve ever used Express middleware chains or jQuery’s fluent API. Each call to .input(), .use(), or .output() returns a new immutable builder with the previous configuration preserved and a new layer added. What makes this special is that the TypeScript generic parameters track the accumulated types, if an input parser changes the shape of the data, every downstream middleware and the final resolver all know the new type automatically. The builder accumulates not just runtime behavior (an array of middleware functions) but also compile-time knowledge (what the input, output, and context look like at each stage).
🔗 Source: packages/server/src/unstable-core-do-not-import/procedureBuilder.ts
The ProcedureBuilder is the most type-heavy file in tRPC, it uses 8 generic type parameters:
interface ProcedureBuilder<
TContext, // Server context type
TMeta, // Procedure metadata type
TContextOverrides, // Accumulated ctx overrides from middlewares
TInputIn, // What callers must provide
TInputOut, // What resolvers receive (after parsing)
TOutputIn, // What resolvers return
TOutputOut, // What callers receive (after output parsing)
TCaller extends boolean // Whether experimental_caller was set
>Immutable Builder Pattern
Each builder method calls createNewBuilder(def1, def2) which clones the builder with merged _def:
function createNewBuilder(def1, def2) {
const { middlewares = [], inputs, meta, ...rest } = def2;
return createBuilder({
...mergeWithoutOverrides(def1, rest),
inputs: [...def1.inputs, ...(inputs ?? [])],
middlewares: [...def1.middlewares, ...middlewares],
meta: def1.meta && meta ? { ...def1.meta, ...meta } : (meta ?? def1.meta),
});
}Key mechanic: .input() is implemented as a middleware. When you call .input(zodSchema), the builder:
- Calls
getParseFn(input)to detect the parser type (Zod, Valibot, Yup, etc.) - Pushes the raw parser into
_def.inputs - Pushes
createInputMiddleware(parser)into_def.middlewares
flowchart TD
A["publicProcedure"] -->|".input(z.object({...}))"| B["ProcedureBuilder"]
B -->|".use(authMiddleware)"| C["ProcedureBuilder"]
C -->|".output(z.string())"| D["ProcedureBuilder"]
D -->|".query(resolver)"| E["QueryProcedure"]
B --> B1["_def.middlewares:<br/>[inputValidator]"]
C --> C1["_def.middlewares:<br/>[inputValidator, authMiddleware]"]
D --> D1["_def.middlewares:<br/>[inputValidator, authMiddleware,<br/>outputValidator]"]
E --> E1["_def.middlewares:<br/>[inputValidator, authMiddleware,<br/>outputValidator, resolveMiddleware]"]
style E fill:#d4edda,stroke:#333,stroke-width:2pxInput Validation Middleware
async function inputValidatorMiddleware(opts) {
const rawInput = await opts.getRawInput();
const parsedInput = await parse(rawInput); // May throw TRPCError(BAD_REQUEST)
// Multiple .input() calls merge via object spread
const combinedInput = isObject(opts.input) && isObject(parsedInput)
? { ...opts.input, ...parsedInput }
: parsedInput;
return opts.next({ input: combinedInput });
}Output Validation Middleware
async function outputValidatorMiddleware({ next }) {
const result = await next();
if (!result.ok) return result; // Pass through errors
const data = await parse(result.data); // Throws INTERNAL_SERVER_ERROR on fail
return { ...result, data };
}Terminal Methods: .query(), .mutation(), .subscription()
These methods finalize the builder by wrapping the user’s resolver as the last middleware in the chain:
async function resolveMiddleware(opts) {
const data = await resolver(opts);
return {
marker: middlewareMarker, // Compile-time guarantee that next() was called
ok: true,
data,
ctx: opts.ctx,
};
}5. Middleware Resolution Algorithm
When a request hits a procedure, all those middleware functions you chained together need to run in a specific order. The algorithm is elegant in its simplicity: an array of functions walked by index, where each function receives a next callback that advances to the next index. This is the same concept as Express middleware or Koa’s onion model, you can run code before calling next(), after next() returns, or both. The critical design choice is that errors are caught at each layer and converted to a structured { ok: false, error } result rather than propagating as thrown exceptions. This means a middleware can inspect the downstream result and decide whether to retry, transform the error, or pass it through unchanged.
Source: procedureBuilder.ts:callRecursive
The middleware chain is resolved via index-based recursion through a flat array:
async function callRecursive(index, _def, opts): Promise<MiddlewareResult> {
try {
const middleware = _def.middlewares[index]!;
const result = await middleware({
...opts,
meta: _def.meta,
input: opts.input,
next(_nextOpts) {
return callRecursive(index + 1, _def, {
...opts,
ctx: _nextOpts?.ctx
? { ...opts.ctx, ..._nextOpts.ctx }
: opts.ctx,
input: 'input' in (_nextOpts ?? {})
? _nextOpts.input
: opts.input,
getRawInput: _nextOpts?.getRawInput ?? opts.getRawInput,
});
},
});
return result;
} catch (cause) {
return {
ok: false,
error: getTRPCErrorFromUnknown(cause),
marker: middlewareMarker,
};
}
}How It Works
sequenceDiagram
participant P as procedure()
participant R as callRecursive(0)
participant M1 as inputValidator
participant M2 as authMiddleware
participant M3 as outputValidator
participant M4 as resolveMiddleware
P->>R: opts = { ctx, getRawInput, signal }
R->>M1: middleware(opts)
M1->>M1: parse rawInput → input
M1->>R: next({ input })
Note over R: index = 1
R->>M2: middleware(opts + input)
M2->>M2: check ctx.session
alt No session
M2-->>R: throw TRPCError(UNAUTHORIZED)
R-->>P: { ok: false, error }
else Has session
M2->>R: next({ ctx: { session } })
end
Note over R: index = 2
R->>M3: middleware(opts + session)
M3->>R: next()
Note over R: index = 3
R->>M4: resolveMiddleware(opts)
M4->>M4: const data = await resolver(opts)
M4-->>M3: { ok: true, data }
M3->>M3: parse(data) via outputValidator
M3-->>M2: { ok: true, data: validatedData }
M2-->>M1: { ok: true, data }
M1-->>R: { ok: true, data }
R-->>P: { ok: true, data }Key properties of this algorithm:
- Order preservation: Middlewares execute in the order they were added to
_def.middlewares - Context accumulation: Each middleware’s
ctxoverride is merged via spread ({ ...opts.ctx, ...nextOpts.ctx }) - Error short-circuiting: If any middleware throws, the error is caught and the chain returns
{ ok: false } - Type narrowing: The TypeScript types track context overrides so downstream middleware sees non-nullable fields
6. The Router: Building and Merging
A router is just a container for procedures, but the way tRPC stores them internally is clever. When you define router({ users: userRouter, posts: postRouter }), the builder recursively walks the tree and produces two parallel data structures: a nested record that preserves the original shape (so the client proxy can traverse it), and a flat dictionary keyed by dotted paths like 'users.getById' (so the server can look up any procedure in O(1) time at request dispatch). This dual representation means the developer experience of writing router({ users: { list: ... } }) maps cleanly to both the type system and the runtime lookup engine. The router also supports lazy loading via dynamic imports, which is essential for large codebases where you don’t want every procedure loaded at startup.
Source: packages/server/src/unstable-core-do-not-import/router.ts
Core Data Structure
interface RouterDef<TRoot, TRecord> {
_config: RootConfig<TRoot>;
router: true;
procedures: Record<string, AnyProcedure>; // FLAT: dotted-path → procedure
record: TRecord; // NESTED: original tree shape
lazy: Record<string, LazyLoader>; // Keyed by dotted path
}A router maintains two parallel structures:
recordthe original nested tree (used by the client proxy to understand shape)proceduresa flat dictionary keyed by dotted path (used for O(1) lookup at request time)
The step() Function
When you call t.router({ users: userRouter, posts: postRouter }), the step() function recursively walks the input:
flowchart TD
INPUT["Input:<br/>{ users: userRouter, posts: postRouter }"]
INPUT --> STEP["step(input, path=[])"]
STEP --> CHECK{For each key}
CHECK -->|"isLazy(item)"| LAZY["Register lazy loader<br/>defer until accessed"]
CHECK -->|"isRouter(item)"| RECURSE["step(item._def.record,<br/>[...path, key])"]
CHECK -->|"isProcedure(item)"| REGISTER["procedures[path.key] = item<br/>aggregate[key] = item"]
CHECK -->|"plain object"| NEST["step(item, [...path, key])"]
RECURSE --> STEP
NEST --> STEP
REGISTER --> OUTPUT
LAZY --> OUTPUT
OUTPUT["Result:<br/>procedures = { 'users.list': ..., 'posts.create': ... }<br/>record = { users: { list: ..., create: ... } }"]
style OUTPUT fill:#d4edda,stroke:#333,stroke-width:2pxLazy Loading
For large routers, procedures can be loaded on demand via dynamic imports:
const appRouter = router({
// This won't be imported until first accessed
analytics: lazy(() => import('./routers/analytics')),
});The lazy loader uses once() to memoize the dynamic import happens only on first access:
function createLazyLoader(opts) {
return {
ref: opts.ref,
load: once(async () => {
const router = await opts.ref();
opts.aggregate[opts.key] = step(router._def.record, opts.path);
// Recursively register nested lazy loaders
for (const [key, item] of Object.entries(router._def.lazy)) {
lazy[`${opts.path}.${key}`] = createLazyLoader({ ... });
}
}),
};
}Router Merging
mergeRouters(a, b, c) uses mergeWithoutOverrides which throws on duplicate keys:
const record = mergeWithoutOverrides(
{},
...routerList.map((r) => r._def.record),
);Reserved Words
The names ['then', 'call', 'apply'] are forbidden as procedure or router names. This is critical because:
- A Proxy with a
thenmethod makes JavaScript treat it as a thenablePromise.resolve(proxy)would recurse forever callandapplyare handled specially by the proxy for explicitthisbinding
7. The Proxy Magic
When you write trpc.user.getById.query('1'), you’re not calling a function you’re traversing a chain of JavaScript Proxy objects that record each property access as a path segment. This is the trick that makes tRPC feel like you’re calling functions directly, when in reality the proxy is intercepting every dot access and building up a path array behind the scenes. When you finally call .query() or .mutate(), the apply trap fires and the accumulated path plus arguments are handed to a callback that performs the actual network request. The implementation has to guard against several edge cases: then must return undefined to prevent infinite Promise coercion, and toString/valueOf must return debug strings so React’s rendering pipeline doesn’t recurse into the proxy. This is arguably the most clever piece of engineering in the entire codebase a few dozen lines of Proxy handler code create the illusion of a type-safe remote function call.
Source: packages/server/src/unstable-core-do-not-import/createProxy.ts
tRPC’s type-safe client experience is powered by JavaScript Proxy objects. The proxy accumulates a path array as you chain property accesses, then fires a callback when you call it as a function.
createRecursiveProxy(callback)
function createInnerProxy(callback, path, memo) {
const cacheKey = path.join('.');
memo[cacheKey] ??= new Proxy(noop, {
// Property access → extend the path
get(_obj, key) {
if (typeof key !== 'string' || key === 'then') return undefined;
return createInnerProxy(callback, [...path, key], memo);
},
// Function call → invoke callback with accumulated path
apply(_1, _2, args) {
const lastOfPath = path[path.length - 1];
// Special method guards
if (lastOfPath === 'valueOf' || lastOfPath === 'toString' || lastOfPath === 'toJSON') {
return `tRPC.proxy(${path.slice(0, -1).join('.')})`;
}
return callback({ args, path });
},
});
return memo[cacheKey];
}Key implementation details:
memocache: Each unique path gets a single Proxy instance, soproxy.a.b.calways returns the same objectthenguard: Returnsundefinedto prevent Promise-coercion recursionvalueOf/toString/toJSONguards: Return debug strings to prevent infinite recursion during React rendering or console logging
How the Client Proxy Works
flowchart LR
A["client.user.getById<br/>.query('1')"]
subgraph "Recursive Proxy"
direction TB
B["path = []"]
B --> C[".user → path = ['user']"]
C --> D[".getById → path = ['user', 'getById']"]
D --> E[".query('1') → apply trap fires"]
end
E --> F["callback({ path: ['user','getById','query'],<br/>args: ['1'] })"]
F --> G["Pop 'query' as procedureType<br/>fullPath = 'user.getById'"]
G --> H["client.query('user.getById', '1')"]
style A fill:#e0f0ff,stroke:#333
style H fill:#d4edda,stroke:#333,stroke-width:2px// packages/client/src/createTRPCClient.ts
export function createTRPCClientProxy<TRouter>(client: TRPCUntypedClient<TRouter>) {
const proxy = createRecursiveProxy(({ path, args }) => {
const pathCopy = [...path];
const callType = pathCopy.pop()!; // 'query', 'mutate', or 'subscribe'
const fullPath = pathCopy.join('.');
const procedureType = clientCallTypeMap[callType];
return client[procedureType](fullPath, ...args);
});
return createFlatProxy((key) => {
if (key === untypedClientSymbol) return client;
return proxy[key];
});
}How the Server Caller Proxy Works
The server-side caller (createCallerFactory) uses the same recursive proxy but invokes procedures directly:
return function createCaller(ctxOrCallback) {
return createRecursiveProxy(async ({ path, args }) => {
const fullPath = path.join('.');
const procedure = await getProcedureAtPath(router, fullPath);
if (!procedure) {
throw new TRPCError({ code: 'NOT_FOUND', message: `No procedure on path "${fullPath}"` });
}
const ctx = isFunction(ctxOrCallback)
? await Promise.resolve(ctxOrCallback())
: ctxOrCallback;
return procedure({
path: fullPath,
getRawInput: async () => args[0],
ctx,
type: procedure._def.type,
signal: opts?.signal,
batchIndex: 0,
});
});
};So caller.user.getById('1') produces path = ['user', 'getById'], joins to 'user.getById', looks up the procedure, and invokes it through the middleware chain.
8. The Link Chain
Links are the client-side equivalent of server middleware, a pipeline of functions that each get a chance to inspect, modify, or redirect an operation before it reaches the network. The chain is built once at client creation time, and each link is called in order for every operation. Non-terminating links (like loggerLink or retryLink) wrap the next() call to add behavior around the request, while terminating links (like httpBatchLink or httpSubscriptionLink) actually perform the HTTP request and produce a response. This architecture lets you compose cross-cutting concerns, logging, retrying, splitting subscriptions from queries, without modifying any individual link’s code. The recursive execution function is surprisingly short, but it enables a powerful composition model that would be hard to achieve with plain function calls.
Source: packages/client/src/links/internals/createChain.ts
Links form a chain of responsibility on the client side. Each link is a function that receives an operation and a next function to pass it downstream.
Link Interface
type OperationLink<TRouter> = (opts: {
op: Operation; // { id, type, input, path, context, signal }
next: (op: Operation) => Observable<OperationResultEnvelope>;
}) => Observable<OperationResultEnvelope>;Chain Execution
function createChain(opts) {
return observable((observer) => {
function execute(index = 0, op = opts.op) {
const link = opts.links[index];
if (!link) throw new Error('No more links to execute');
return link({
op,
next(nextOp) {
return execute(index + 1, nextOp);
},
});
}
return execute().subscribe(observer);
});
}flowchart LR
OP["Operation<br/>{ type: 'query', path: 'user.getById', input: '1' }"]
OP --> L1["loggerLink<br/>(non-terminating)"]
L1 -->|"next(op)"| L2["splitLink<br/>(non-terminating)"]
L2 -->|"condition: true"| L3a["httpSubscriptionLink<br/>(terminating)"]
L2 -->|"condition: false"| L3b["httpBatchLink<br/>(terminating)"]
L3a --> SERVER["HTTP Server (SSE)"]
L3b --> SERVER2["HTTP Server (JSON)"]
style L3a fill:#ffe6cc,stroke:#333
style L3b fill:#d4edda,stroke:#333
style L1 fill:#e0f0ff,stroke:#333
style L2 fill:#e0f0ff,stroke:#333Terminating vs. Non-Terminating Links
| Link | Type | Behavior |
|---|---|---|
httpBatchLink | Terminating | Actually sends HTTP request |
httpLink | Terminating | Sends single HTTP request |
httpSubscriptionLink | Terminating | Opens SSE connection |
httpBatchStreamLink | Terminating | Streams batched responses |
wsLink | Terminating | Opens WebSocket connection |
loggerLink | Non-terminating | Logs and calls next() |
retryLink | Non-terminating | Retries on failure |
splitLink | Non-terminating | Routes to different links |
Terminating links don’t call next() they perform the actual network request. Non-terminating links wrap next() to intercept and transform operations.
9. The DataLoader and Batching
If your React component tree fires five separate queries on mount, you don’t want five HTTP requests, you want one. The DataLoader makes this happen by collecting all operations queued in the same JavaScript tick and flushing them as a single batched request on the next macrotask. The mechanism exploits a fundamental property of the JavaScript event loop: synchronous code always runs to completion before any timer callback fires, so any number of .query() calls in the same render cycle are guaranteed to be in the queue before the flush happens. On the server side, each call in the batch runs independently in parallel, and the results are assembled into an array that maps back to each caller’s Promise. This is one of tRPC’s most impactful performance features, it eliminates the N+1 request problem that plagues component-based UIs without requiring any developer awareness.
Source: packages/client/src/internals/dataLoader.ts
The batching magic is powered by a lightweight DataLoader implementation. Multiple procedure calls in the same JavaScript tick are collected and sent as a single HTTP request.
How It Works
sequenceDiagram
participant C1 as Component A
participant C2 as Component B
participant C3 as Component C
participant DL as dataLoader
participant Timer as setTimeout
participant HTTP as HTTP Server
par Same JavaScript tick
C1->>DL: load(query1)
Note over DL: pendingItems = [query1]
DL->>Timer: schedule dispatch()
C2->>DL: load(query2)
Note over DL: pendingItems = [query1, query2]
C3->>DL: load(query3)
Note over DL: pendingItems = [query1, query2, query3]
end
Timer->>DL: dispatch()
Note over DL: groupItems → 1 batch
DL->>HTTP: POST /api/trpc?batch=1<br/>Body: {0: query1, 1: query2, 2: query3}
HTTP-->>DL: [result1, result2, result3]
DL->>C1: resolve(result1)
DL->>C2: resolve(result2)
DL->>C3: resolve(result3)function dataLoader<TKey, TValue>(batchLoader) {
let pendingItems = null;
let dispatchTimer = null;
function load(key) {
const item = {
aborted: false,
key,
batch: null,
resolve: throwFatalError,
reject: throwFatalError,
};
const promise = new Promise((resolve, reject) => {
item.reject = reject;
item.resolve = resolve;
pendingItems ??= [];
pendingItems.push(item);
});
// Schedule flush on next macrotask
dispatchTimer ??= setTimeout(dispatch);
return promise;
}
function dispatch() {
// Clear timer
clearTimeout(dispatchTimer);
dispatchTimer = null;
// Snapshot and clear pending items
const items = pendingItems;
pendingItems = null;
// Group into batches (respecting maxURLLength, maxItems)
const grouped = groupItems(items, batchLoader.validate);
// Execute each batch
for (const batch of grouped) {
batchLoader.fetch(batch.keys).then(
(results) => batch.items.forEach((item, i) => item.resolve(results[i])),
(error) => batch.items.forEach((item) => item.reject(error)),
);
}
}
return { load };
}Why this works: JavaScript’s event loop guarantees that synchronous code runs to completion before any setTimeout callback fires. So all .query() calls in the same synchronous block are guaranteed to be collected before dispatch() runs.
10. The Observable Implementation
tRPC needs a way to represent asynchronous data streams for subscriptions that push multiple values over time, and for link chains that might retry or multicast operations. Most libraries solve this by pulling in RxJS, which is powerful but adds significant bundle weight. tRPC instead ships its own tiny Observable implementation: less than 100 lines of code, zero dependencies, and just enough API surface to support the subscribe, next, error, complete, and pipe patterns the rest of the codebase needs. The implementation guarantees that after an error or complete event, no further values are emitted a property that prevents subtle bugs in cleanup logic. Bridge functions convert between Observables and Promises, ReadableStreams, and AsyncIterables, which is how the modern async-generator subscription API coexists with the Observable-based link chain.
Source: packages/server/src/observable/observable.ts
tRPC ships a zero-dependency Observable implementation rather than pulling in RxJS. This keeps the client bundle tiny (~12kb gzipped).
export function observable<TValue, TError>(subscribe) {
const self: Observable<TValue, TError> = {
subscribe(observer) {
let teardownRef = null;
let isDone = false;
let unsubscribed = false;
let teardownImmediately = false;
function unsubscribe() {
teardownRef?.();
teardownRef = null;
}
teardownRef = subscribe({
next(value) {
if (isDone) return;
observer.next?.(value);
},
error(err) {
if (isDone) return;
isDone = true;
observer.error?.(err);
unsubscribe();
},
complete() {
if (isDone) return;
isDone = true;
observer.complete?.();
unsubscribe();
},
});
if (teardownImmediately) unsubscribe();
return { unsubscribe };
},
pipe(...operations) {
return operations.reduce(pipeReducer, self);
},
};
return self;
}Adapters
The file also provides bridge functions:
| Adapter | Purpose |
|---|---|
observableToPromise | Resolves on first next, rejects on error |
observableToReadableStream | Bridges to web ReadableStream<Result<T>> |
observableToAsyncIterable | Wraps a ReadableStream reader in an async iterator |
These bridges unify the Observable-based subscription protocol with the modern AsyncIterable-based API.
11. The RPC Protocol
tRPC doesn’t invent a wire format from scratch, it builds on JSON-RPC 2.0, a well-understood standard with predictable request and response shapes. Every tRPC call is a JSON-RPC message with a method (query, mutation, or subscription), a params object containing the procedure path and input, and a unique ID for matching batched responses. The error codes borrow JSON-RPC’s reserved negative range (-32000 to -32099) and map the last three digits to HTTP status codes, so a NOT_FOUND error carries code -32004 and maps to HTTP 404. Understanding the wire protocol matters when you need to debug with raw network tools, integrate with non-JavaScript clients, or write a custom adapter for a platform tRPC doesn’t officially support yet.
Source: packages/server/src/unstable-core-do-not-import/rpc/
tRPC uses a JSON-RPC 2.0 variant as its wire protocol.
Error Codes
The error codes borrow JSON-RPC 2.0’s reserved -32000 to -32099 range:
PARSE_ERROR: -32700,
BAD_REQUEST: -32600,
INTERNAL_SERVER_ERROR: -32603,
UNAUTHORIZED: -32001,
FORBIDDEN: -32003,
NOT_FOUND: -32004,
TIMEOUT: -32008,
CONFLICT: -32009,
CLIENT_CLOSED_REQUEST: -32099,Wire Format
A tRPC request looks like:
{
"id": 1,
"jsonrpc": "2.0",
"method": "query",
"params": {
"path": "user.getById",
"input": { "id": "123" }
}
}A successful response:
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"data": { "id": "123", "name": "Alice" }
}
}For batched requests, the body is an array of request objects.
SSE Subscription Messages
Subscriptions add special message types:
// Server → Client: subscription started
{ "id": 1, "jsonrpc": "2.0", "result": { "type": "started" } }
// Server → Client: data event
{ "id": 1, "jsonrpc": "2.0", "result": { "type": "data", "data": {...} } }
// Server → Client: subscription ended
{ "id": 1, "jsonrpc": "2.0", "result": { "type": "stopped" } }
// Server → Client: reconnect needed (e.g., during redeployment)
{ "jsonrpc": "2.0", "method": "reconnect" }Message Parsing
parseTRPCMessage(obj, transformer) on the server uses assertion functions:
function parseTRPCMessage(obj, transformer) {
assertIsObject(obj);
const { id, jsonrpc, method, params } = obj;
assertIsRequestId(id);
assertIsJSONRPC2OrUndefined(jsonrpc);
if (method === 'subscription.stop') return { id, jsonrpc, method };
assertIsProcedureType(method);
assertIsObject(params);
const { input: rawInput, path, lastEventId } = params;
assertIsString(path);
const input = transformer.input.deserialize(rawInput);
return { id, jsonrpc, method, params: { input, path, lastEventId } };
}12. The Parser System
Input validation is not optional in a real API, you can never trust what the client sends. tRPC could have locked you into one validation library, but instead it supports Zod, Valibot, Yup, Superstruct, ArkType, Scale, and any library implementing the standard-schema spec. It achieves this through duck-typing: a getParseFn() function inspects the shape of the validator object at runtime and picks the right extraction method, .parse() for Zod, .validateSync() for Yup, .assert() for Scale, and so on. This means your choice of validation library is a local concern that doesn’t leak into the framework. The type-level inference system mirrors this flexibility, extracting { in, out } type pairs from each library’s structural interface so your resolver receives the correctly typed parsed input.
Source: packages/server/src/unstable-core-do-not-import/parser.ts
tRPC supports a huge matrix of validation libraries through duck-typing. Each library has a structural interface (“esque”):
| Interface | Libraries |
|---|---|
ParserZodEsque | Zod (._input, ._output) |
ParserValibotEsque | Valibot (.schema._types) |
ParserArkTypeEsque | ArkType (.inferIn, .infer) |
ParserStandardSchemaEsque | Any standard-schema-compliant lib |
ParserMyZodEsque | myzod (.parse) |
ParserSuperstructEsque | Superstruct (.create) |
ParserYupEsque | Yup (.validateSync) |
ParserScaleEsque | Scale (.assert) |
ParserCustomValidatorEsque | Custom function (input) => T |
getParseFn() Runtime Duck-Typing
The parser detection runs in order:
function getParseFn(procedureParser) {
// 1. ArkType (functions that return unions)
if (typeof parser === 'function' && typeof parser.assert === 'function') return parser.assert;
// 2. Valibot v0.31+ or custom function
if (typeof parser === 'function' && !isStandardSchema) return parser;
// 3. Zod async
if ('parseAsync' in parser) return parser.parseAsync.bind(parser);
// 4. Zod sync / Valibot <v0.13
if ('parse' in parser) return parser.parse.bind(parser);
// 5. Yup
if ('validateSync' in parser) return parser.validateSync.bind(parser);
// 6. Superstruct
if ('create' in parser) return parser.create.bind(parser);
// 7. Scale
if ('assert' in parser) return parser.assert.bind(parser);
// 8. Standard Schema spec
if ('~standard' in parser) return (value) => parser['~standard'].validate(value);
throw new Error('Could not find a validator fn');
}The standard-schema branch is notable, tRPC vendors the standard-schema spec to support any compliant validation library.
13. Data Transformers
JSON is the universal data format for web APIs, but it has a well-known limitation: it flattens Date objects into strings, loses Map and Set semantics, and can’t distinguish undefined from null. In a type-safe system like tRPC, this creates a painful mismatch, your server says createdAt: Date but your client receives createdAt: string. Data transformers solve this by wrapping the serialization and deserialization steps with a codec that preserves rich type information across the wire. The transformer is split into two halves: one for the client-to-server direction (input) and one for the server-to-client direction (output), each with its own serialize and deserialize functions. In v11, the transformer is configured on the client link rather than the framework instance, which gives you more flexibility, different clients can use different transformers if needed.
Source: packages/server/src/unstable-core-do-not-import/transformer.ts
A CombinedDataTransformer has two halves, one for each direction of data flow:
interface CombinedDataTransformer {
input: { serialize, deserialize }; // client → server
output: { serialize, deserialize }; // server → client
}flowchart LR
subgraph Client
CS["input.serialize()"]
end
WIRE["Wire (HTTP)"]
subgraph Server
SD["input.deserialize()"]
SR["output.serialize()"]
end
subgraph Client2
CD["output.deserialize()"]
end
CS -->|"client → server"| WIRE
WIRE --> SD
SR -->|"server → client"| WIRE
WIRE --> CDThe default transformer is identity:
const defaultTransformer = {
input: { serialize: (o) => o, deserialize: (o) => o },
output: { serialize: (o) => o, deserialize: (o) => o },
};For superjson:
const t = initTRPC.create({
transformer: superjson, // Both halves configured
});In v11, the transformer is also set on the client link (not just the server):
// Client
httpBatchLink({
url: '/api/trpc',
transformer: superjson, // v11: moved here from createTRPCNext
});14. HTTP Adapters and Request Dispatch
Every HTTP request that reaches a tRPC server, whether through Express, Fastify, Next.js, or the Fetch API, eventually arrives at the same function: resolveResponse(). This is the master dispatcher that parses the incoming request, creates the context, looks up the procedure, runs the middleware chain, formats the response, and handles errors. The adapters themselves are deliberately thin: they translate framework-specific request and response objects into a common shape and delegate everything else. This design means tRPC can support new frameworks with minimal code, a new adapter is typically under 80 lines. The dispatch function also handles content negotiation, supporting plain JSON, multipart/form-data for file uploads, and application/octet-stream for binary streams, all through pluggable content-type handlers.
Source: packages/server/src/unstable-core-do-not-import/http/resolveResponse.ts
resolveResponse(opts) is the master function that every HTTP adapter ultimately calls. Whether you use Fetch, Express, Fastify, or Next.js, the dispatch logic is the same.
Adapter → resolveResponse Flow
flowchart TD
subgraph "Framework Adapters (Thin Wrappers)"
FETCH["fetchRequestHandler<br/>(Next.js App Router, Remix)"]
EXPRESS["createExpressMiddleware"]
FASTIFY["fastifyTRPCPlugin"]
STANDALONE["createHTTPServer"]
end
FETCH --> RR["resolveResponse()"]
EXPRESS --> RR
FASTIFY --> RR
STANDALONE --> RR
RR --> GRI["getRequestInfo()"]
GRI --> CTH["ContentType Handler<br/>(JSON / FormData / OctetStream)"]
CTH --> CC["createContext()"]
CC --> DISPATCH["Dispatch to procedures"]
DISPATCH --> SINGLE["Single call →<br/>JSON response"]
DISPATCH --> BATCH["Batch call →<br/>JSON array response"]
DISPATCH --> STREAM["Batch + JSONL →<br/>Streamed response"]
DISPATCH --> SSE["Subscription →<br/>SSE stream"]
style RR fill:#ffe6cc,stroke:#333,stroke-width:2pxRequest Dispatch Steps
- HEAD shortcut: Returns
204 No Content(used for warmup) - Parse request:
getRequestInfo()determines batching, procedure type, input - Create context: Calls user’s
createContext({ req, info }) - Validate HTTP method: Query → GET, Mutation → POST, Subscription → GET
- Execute procedures: Each call runs through the middleware chain
- Format response: Depends on type (JSON, batch array, SSE stream, JSONL stream)
Content-Type Handlers
| Handler | Match | Use Case |
|---|---|---|
jsonContentTypeHandler | application/json | Standard requests |
formDataContentTypeHandler | multipart/form-data | File uploads |
octetStreamContentTypeHandler | application/octet-stream | Raw binary |
For GET requests with no content-type, falls back to JSON handler (so URLs work in browsers).
HTTP Method Mapping
const TYPE_ACCEPTED_METHOD_MAP = {
mutation: ['POST'],
query: ['GET'],
subscription: ['GET'], // SSE uses GET
};
// With method override (opt-in)
const TYPE_ACCEPTED_METHOD_MAP_WITH_OVERRIDE = {
mutation: ['POST'],
query: ['GET', 'POST'], // POST can also query
subscription: ['GET', 'POST'],
};15. SSE Stream Producer
Subscriptions in tRPC v11 are delivered over Server-Sent Events, a standard browser protocol for one-way server-to-client streaming over plain HTTP. The SSE stream producer takes an AsyncIterable, the values your subscription generator yields, and converts it into a ReadableStream<Uint8Array> formatted as SSE events with proper event:, data:, and id: fields. The implementation handles several production concerns that are easy to get wrong: periodic ping comments to prevent proxy timeouts, a maxDurationMs timer for serverless environments, and tracked envelopes that enable Last-Event-ID resumption when a connection drops and reconnects. Error events are emitted as structured SSE messages rather than killing the stream, so clients can decide whether to retry or surface the error.
Source: packages/server/src/unstable-core-do-not-import/stream/sse.ts
sseStreamProducer() converts an AsyncIterable<TValue> into a ReadableStream<Uint8Array> conforming to the HTML Server-Sent Events spec.
sequenceDiagram
participant P as Procedure (AsyncGenerator)
participant S as sseStreamProducer
participant R as ReadableStream
participant C as Client (httpSubscriptionLink)
C->>S: GET /api/trpc/message.onUpdate
S->>P: Start iterating
S-->>C: event: connected<br/>data: {"client": {...}}
loop While generator yields
P->>S: yield { message: "Hello" }
S-->>C: id: tracked-id<br/>event: data<br/>data: {"message":"Hello"}
end
Note over S: Every 15s (configurable)
S-->>C: :ping (comment, not data)
P->>S: generator returns
S-->>C: event: return<br/>data: null
Note over C: Subscription endedSSE Event Types
| Event | Purpose |
|---|---|
connected | First message with client options |
data | Data yielded by the generator |
return | Generator completed normally |
ping | Keepalive comment (every 15s by default) |
serialized-error | Error during generation |
Tracked Envelopes
If a yielded value is { id, data }, the id becomes the SSE event-id header. This enables Last-Event-ID resumption, if the connection drops, the client reconnects with the last event ID, and the server can resume from that point.
16. Error Handling
Errors in a distributed system are harder to get right than success paths, because they need to be both machine-readable and human-friendly. tRPC’s error system is built around a single TRPCError class that carries a semantic code (NOT_FOUND, UNAUTHORIZED, CONFLICT, etc.), a human message, and an optional cause chain for debugging. Every error, whether thrown intentionally in your procedure, caused by a validation failure, or resulting from an uncaught exception, is normalized through getTRPCErrorFromUnknown() which wraps anything that isn’t already a TRPCError with an INTERNAL_SERVER_ERROR code. The error is then shaped through the user’s errorFormatter and serialized for the client, where it arrives as a typed object with a .data.code field the client can switch on without string-matching.
Source: packages/server/src/unstable-core-do-not-import/error/
TRPCError Class
class TRPCError extends Error {
public readonly code: TRPC_ERROR_CODE_KEY;
public override readonly cause?: Error;
constructor(opts: { code; message?; cause? }) {
const cause = getCauseFromUnknown(opts.cause);
const message = opts.message ?? cause?.message ?? opts.code;
super(message, { cause });
this.code = opts.code;
this.name = 'TRPCError';
}
}Error Flow
flowchart TD
ERROR["Error thrown<br/>(any type)"]
ERROR --> GTE["getTRPCErrorFromUnknown()"]
GTE --> CHECK{Already TRPCError?}
CHECK -->|Yes| RETURN["Return as-is"]
CHECK -->|No| WRAP["Wrap as TRPCError<br/>code: INTERNAL_SERVER_ERROR<br/>cause: original error"]
RETURN --> GES["getErrorShape()"]
WRAP --> GES
GES --> SHAPE["Error Shape:<br/>{ message, code, data: { code, httpStatus, path, stack? } }"]
SHAPE --> FORMAT["User errorFormatter()"]
FORMAT --> SER["Serialized via transformer"]
SER --> HTTP["HTTP Response<br/>with appropriate status code"]
style ERROR fill:#f8d7da,stroke:#333
style HTTP fill:#d4edda,stroke:#333,stroke-width:2pxgetCauseFromUnknown()
Handles any thrown value:
function getCauseFromUnknown(cause) {
if (cause instanceof Error) return cause;
if (cause === null || cause === undefined || typeof cause === 'function') return undefined;
if (typeof cause === 'string' || typeof cause === 'number' || typeof cause === 'boolean') {
return new Error(String(cause));
}
// Plain objects
return new UnknownCauseError(cause); // Spreads cause fields onto error
}getTRPCErrorFromUnknown()
function getTRPCErrorFromUnknown(cause) {
if (cause instanceof TRPCError) return cause;
if (cause instanceof Error && cause.name === 'TRPCError') return cause as TRPCError; // cross-realm
const error = new TRPCError({ code: 'INTERNAL_SERVER_ERROR', cause });
error.stack = cause instanceof Error ? cause.stack : error.stack;
return error;
}17. Type Inference Pipeline
The phrase “end-to-end type safety” gets thrown around a lot, but tRPC’s implementation is genuinely impressive when you trace how types flow from server to client. The server’s router type, a deeply nested object of procedure definitions with their input parsers types and output return types, is exported as a type-only import. TypeScript’s compiler walks this tree recursively, mapping each procedure to its client-side representation: query procedures get a .query() method, mutations get .mutate(), and the input/output types are preserved through every transformation layer. The phantom types field ($types: null as any) is the vehicle that carries context types, error shapes, and transformer metadata from the server config all the way to the client’s error handling code. At runtime, none of this type machinery exists, it’s all erased by the compiler, leaving zero overhead.
The end-to-end type safety relies on the phantom types pattern plus recursive type-level programming.
How Types Flow from Server to Client
flowchart TB
subgraph "Server"
SR["appRouter = router({...})"]
AT["type AppRouter = typeof appRouter"]
end
subgraph "Type-Only Import (stripped at build)"
TI["import type { AppRouter }"]
end
subgraph "Client"
CC["createTRPCClient<AppRouter>()"]
DPR["DecoratedProcedureRecord<br/>walks router type recursively"]
HOOKS["client.user.getById.query(input)<br/>← fully typed!"]
end
SR --> AT
AT -->|"type-only"| TI
TI --> CC
CC --> DPR
DPR --> HOOKS
style TI fill:#fff3cd,stroke:#333,stroke-dasharray: 5 5
style HOOKS fill:#d4edda,stroke:#333,stroke-width:2pxProcedure Type Extraction
interface Procedure<TType, TDef> {
_def: {
$types: { input: TDef['input']; output: TDef['output'] };
procedure: true;
type: TType;
inputs: Parser[];
};
}Client Decoration
DecoratedProcedureRecord walks the router record recursively, mapping each procedure to its client-side representation:
type DecoratedProcedureRecord<TRoot, TRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends AnyProcedure
? DecorateProcedure<
TRecord[TKey]['_def']['type'],
{
input: inferProcedureInput<TRecord[TKey]>;
output: inferTransformedProcedureOutput<inferClientTypes<TRoot>, TRecord[TKey]>;
errorShape: inferClientTypes<TRoot>['errorShape'];
transformer: inferClientTypes<TRoot>['transformer'];
}
>
: TRecord[TKey] extends RouterRecord
? DecoratedProcedureRecord<TRoot, TRecord[TKey]> // ← recurse
: never;
};Procedure Input Inference
type inferProcedureInput<TProcedure> =
undefined extends inferProcedureParams<TProcedure>['$types']['input']
? void | inferProcedureParams<TProcedure>['$types']['input'] // optional
: inferProcedureParams<TProcedure>['$types']['input']; // requiredThe undefined extends X check produces void | X when input is optional (callers can omit it) or just X when required.
18. Complete Request/Response Lifecycle
This chapter ties together every concept from the previous seventeen chapters into a single end-to-end trace. Following a single request from the moment a client calls client.user.getById.query('1') through the proxy, the link chain, the DataLoader batching engine, the HTTP transport, the server adapter, the content-type parser, the procedure dispatch, the middleware chain, the resolver, and back, you see how all the pieces fit. The lifecycle diagram is the single most useful reference for debugging tRPC issues: if something goes wrong, you can trace the request through each step and identify exactly where it broke. Whether you’re investigating a batched request that didn’t batch, a mutation that returned the wrong type, or a subscription that won’t reconnect, the answer is somewhere in this flow.
Here is the full lifecycle of client.user.getById.query('1'), tracing through every layer:
sequenceDiagram
autonumber
participant Client as Client Component
participant Proxy as Recursive Proxy
participant UC as TRPCUntypedClient
participant Chain as Link Chain
participant Batch as DataLoader
participant HTTP as HTTP Transport
participant Adapter as Fetch Adapter
participant RR as resolveResponse()
participant Proc as Procedure
participant MW as Middleware Chain
participant Resolver as User Resolver
Client->>Proxy: client.user.getById.query('1')
Note over Proxy: path = ['user','getById','query']<br/>apply trap fires, args = ['1']
Proxy->>UC: client.query('user.getById', '1')
UC->>UC: Create Operation<br/>{id: 1, type: 'query', path: 'user.getById', input: '1'}
UC->>Chain: createChain({links, op})
Chain->>Chain: loggerLink wraps next()
Chain->>Batch: httpBatchLink → loader.load(op)
Note over Batch: Queued in pendingItems[]<br/>setTimeout(dispatch) scheduled
Note over Batch: Next macrotask: dispatch() runs
Batch->>HTTP: POST /api/trpc/user.getById?batch=1<br/>Body: {"0":{"json":"1"}}
HTTP->>Adapter: HTTP Request arrives
Adapter->>RR: resolveResponse({router, req, path, createContext})
RR->>RR: getRequestInfo()<br/>deserialize input via transformer
RR->>RR: createContext({req, info})
RR->>Proc: procedure({path, getRawInput, ctx, signal})
Proc->>MW: callRecursive(0, _def, opts)
MW->>MW: [0] inputValidator: parse('1') → input = '1'
MW->>MW: [1] authMiddleware: check ctx.session ✓
MW->>MW: [2] outputValidator: next()
MW->>Resolver: [3] resolveMiddleware: await resolver({ctx, input})
Resolver-->>MW: return {id: '1', name: 'Alice'}
MW->>MW: [2] outputValidator: parse(result)
MW-->>Proc: {ok: true, data: {id:'1', name:'Alice'}}
Proc-->>RR: data
RR->>RR: transformTRPCResponse()<br/>serialize via transformer.output
RR-->>Adapter: Response(JSON, {status: 200})
Adapter-->>HTTP: HTTP Response
HTTP-->>Batch: {jsonrpc:'2.0', result:{data:{...}}}
Batch->>Batch: transformResult()<br/>deserialize via transformer.output
Batch-->>Chain: Observable.next({result})
Chain-->>UC: Observable completes
UC-->>Proxy: Promise resolves
Proxy-->>Client: {id: '1', name: 'Alice'}Lifecycle Summary
flowchart TD
subgraph "Client Side"
A["client.user.getById.query('1')"]
B["Recursive Proxy<br/>accumulates path"]
C["TRPCUntypedClient<br/>creates Operation"]
D["Link Chain<br/>loggerLink → httpBatchLink"]
E["DataLoader<br/>collects same-tick calls"]
F["HTTP Request<br/>POST /api/trpc"]
end
subgraph "Server Side"
G["Framework Adapter<br/>fetchRequestHandler"]
H["resolveResponse()<br/>parse + createContext"]
I["getProcedureAtPath()<br/>O(1) lookup"]
J["Middleware Chain<br/>input → auth → output → resolver"]
K["Response<br/>serialized via transformer"]
end
A --> B --> C --> D --> E --> F
F --> G --> H --> I --> J --> K
K -.->|"HTTP Response"| F
F -.->|"transformResult()"| L["Client receives<br/>typed data"]
K -.-> L
style A fill:#e0f0ff,stroke:#333
style J fill:#ffe6cc,stroke:#333,stroke-width:2px
style L fill:#d4edda,stroke:#333,stroke-width:2px19. Key Algorithms Summary
| Algorithm | Location | Mechanism |
|---|---|---|
| Procedure builder | procedureBuilder.ts | Immutable clone via createNewBuilder, accumulates _def.middlewares |
| Middleware resolution | procedureBuilder.ts:callRecursive | Index-based recursion with next() closure |
| Router flattening | router.ts:step | Recursive walk producing flat procedures map + nested record |
| Lazy loading | router.ts:createLazyLoader | once()-memoized dynamic import |
| Recursive Proxy | createProxy.ts:createInnerProxy | Path-array accumulation + memo cache per path |
| Client proxy decoration | createTRPCClient.ts | Pop last path segment as verb, rest as procedure path |
| Link chain | links/internals/createChain.ts:execute | Recursive link factory invocation returning Observables |
| Batching | internals/dataLoader.ts | Macrotask-batching with setTimeout(dispatch), groupItems splitter |
| SSE streaming | stream/sse.ts:sseStreamProducer | AsyncIterable → ReadableStream with ping events + tracked IDs |
| RPC parsing | rpc/parseTRPCMessage.ts | Series of assertIs* runtime guards |
| Content negotiation | http/contentType.ts | Pluggable handlers per content-type |
| Error shaping | error/getErrorShape.ts | TRPCError → DefaultErrorShape via user errorFormatter |
| HTTP dispatch | http/resolveResponse.ts | Four branches: single, subscription, JSONL stream, batch |
| Parser detection | parser.ts:getParseFn | Ordered duck-typing across validation libraries |
Architecture at a Glance
graph TB
subgraph "Client"
CP["Client Proxy<br/>(createRecursiveProxy)"]
UC["TRPCUntypedClient"]
LC["Link Chain<br/>(logger → batch)"]
DL["DataLoader<br/>(batching)"]
end
subgraph "Network"
HTTP["HTTP / SSE"]
end
subgraph "Server"
AD["Framework Adapter<br/>(fetch / express / fastify)"]
RR["resolveResponse()"]
CT["Content-Type Handler"]
CX["createContext()"]
RT["Router<br/>(flat procedures map)"]
PR["Procedure<br/>(callRecursive middleware chain)"]
TR["Transformer<br/>(serialize/deserialize)"]
end
CP --> UC --> LC --> DL --> HTTP
HTTP --> AD --> RR
RR --> CT --> CX --> RT --> PR
PR --> TR
TR -.-> HTTP20. Source Code
Source Code
| Component | Path |
|---|---|
| initTRPC | packages/server/src/unstable-core-do-not-import/initTRPC.ts |
| Procedure Builder | packages/server/src/unstable-core-do-not-import/procedureBuilder.ts |
| Middleware | packages/server/src/unstable-core-do-not-import/middleware.ts |
| Router | packages/server/src/unstable-core-do-not-import/router.ts |
| createProxy | packages/server/src/unstable-core-do-not-import/createProxy.ts |
| Parser System | packages/server/src/unstable-core-do-not-import/parser.ts |
| Transformer | packages/server/src/unstable-core-do-not-import/transformer.ts |
| resolveResponse | packages/server/src/unstable-core-do-not-import/http/resolveResponse.ts |
| Content Type | packages/server/src/unstable-core-do-not-import/http/contentType.ts |
| RPC Codes | packages/server/src/unstable-core-do-not-import/rpc/codes.ts |
| SSE Stream | packages/server/src/unstable-core-do-not-import/stream/sse.ts |
| TRPCError | packages/server/src/unstable-core-do-not-import/error/TRPCError.ts |
| Observable | packages/server/src/observable/observable.ts |
| Client Proxy | packages/client/src/createTRPCClient.ts |
| Untyped Client | packages/client/src/internals/TRPCUntypedClient.ts |
| Link Chain | packages/client/src/links/internals/createChain.ts |
| DataLoader | packages/client/src/internals/dataLoader.ts |
| Fetch Adapter | packages/server/src/adapters/fetch/fetchRequestHandler.ts |
Hire Runastartup to build, optimize or scale your software!
This guide is based on the tRPC main branch. The architecture has evolved significantly from v10, notably the rename of core/ to unstable-core-do-not-import/, the move toward async iterables over observables, the addition of standard-schema support, and SSE replacing WebSocket as the default subscription transport.
![]()