Introduction to tRPC Performance
tRPC has revolutionized how developers build full-stack TypeScript applications by providing end-to-end type safety without the need for code generation or schemas. However, as your application scales and traffic increases, the performance of your API layer becomes critical. Understanding how tRPC handles requests under the hood and knowing how to optimize it can mean the difference between a lightning-fast application and a sluggish user experience.
What is tRPC and Why Does Performance Matter?
tRPC is a TypeScript RPC (Remote Procedure Call) framework that allows you to easily build and consume fully type-safe APIs. Because it relies directly on TypeScript's type inference, it eliminates the boilerplate typically associated with REST or GraphQL. Performance matters in tRPC just as it does in any API architecture. While tRPC is inherently lightweight, inefficient procedures, over-fetching of data, and poorly optimized middleware can introduce latency. Optimizing tRPC ensures lower server CPU usage, reduced memory footprint, and faster response times for the end user.
Understanding tRPC Benchmarks
When evaluating tRPC performance, it is helpful to understand how it compares to other API paradigms. Benchmarks consistently show that tRPC introduces minimal overhead compared to raw HTTP handlers, and it generally outperforms GraphQL due to the absence of a complex query parsing and resolution engine. However, tRPC does serialize and deserialize data using "SuperJSON" by default to preserve type fidelity (like Date objects). This serialization step, while incredibly useful, does consume CPU cycles.
Baseline Performance Metrics
To establish a baseline, developers often use tools like autocannon or k6 to stress-test their tRPC endpoints. A standard tRPC procedure executing a simple arithmetic operation can handle tens of thousands of requests per second on a single Node.js instance. The performance drop usually occurs when database queries, authentication, and complex business logic are introduced. Therefore, optimizing tRPC is less about the framework itself and more about how you structure your procedures and data fetching.
Optimization Techniques
1. Leveraging Request Batching
One of the most powerful features of tRPC is automatic request batching. When a client makes multiple tRPC calls in the same render cycle (or tick), the client automatically batches them into a single HTTP request. This drastically reduces network latency and HTTP overhead.
To ensure batching works correctly, you must configure the httpBatchLink on the client side:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
// You can adjust the maximum number of requests in a single batch
maxURLLength: 2083,
}),
],
});
On the client, simply calling multiple procedures synchronously will trigger the batch:
// These two calls will be combined into a single HTTP request
const [user, posts] = await Promise.all([
trpc.user.getById.query({ id: '1' }),
trpc.post.getByUserId.query({ userId: '1' }),
]);
2. Preventing Over-Fetching with Database Selectors
A common performance pitfall is fetching entire database rows when only a few fields are needed. When using an ORM like Prisma with tRPC, always use the select or include options to limit the data retrieved from the database. This reduces database load, memory usage, and the time spent serializing the response.
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const userRouter = router({
getProfile: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { id: input.id },
// Only select the fields necessary for the profile view
select: {
id: true,
name: true,
email: true,
avatarUrl: true,
// Omitting passwordHash, createdAt, updatedAt, etc.
},
});
return user;
}),
});
3. Implementing Caching Strategies
For data that changes infrequently, querying the database on every request is wasteful. You can implement caching directly within your tRPC procedures. Using a simple in-memory cache like lru-cache or a distributed cache like Redis can yield massive performance gains.
import { router, publicProcedure } from '../trpc';
import LRUCache from 'lru-cache';
const cache = new LRUCache<string, any>({
max: 100, // Maximum 100 items
ttl: 1000 * 60 * 5, // 5 minutes
});
export const productRouter = router({
getFeatured: publicProcedure.query(async ({ ctx }) => {
const cacheKey = 'featured-products';
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const products = await ctx.prisma.product.findMany({
where: { isFeatured: true },
take: 10,
});
cache.set(cacheKey, products);
return products;
}),
});
4. Optimizing Middleware
tRPC middleware runs on every request that passes through it. If your authentication or logging middleware performs heavy synchronous operations or makes unnecessary database calls, it will bottleneck your entire application. Ensure middleware is lean and relies on fast lookups (like verifying a JWT token) rather than querying the database on every single request.
import { TRPCError } from '@trpc/server';
import { jwtVerify } from 'jose';
export const isAuthed = t.middleware(async (opts) => {
const { ctx } = opts;
if (!ctx.token) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
try {
// Fast, stateless JWT verification
const { payload } = await jwtVerify(ctx.token, getJwtSecret());
return opts.next({
ctx: {
...ctx,
user: payload,
},
});
} catch (err) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
});
Best Practices for High-Performance tRPC Apps
- Optimize Context Creation: The
createContextfunction runs on every request. Avoid putting expensive initialization logic here. Only attach what is strictly necessary for the procedures. - Use Pagination: Never return entire collections from your database. Implement cursor-based or offset-based pagination to keep response payloads small and memory usage low.
- Monitor with OpenTelemetry: Integrate tRPC with OpenTelemetry to trace request lifecycles. This helps identify exactly which procedure or database query is causing latency.
- Avoid Deeply Nested Calls: While tRPC allows you to call one procedure from another, doing so excessively can lead to N+1 query problems and duplicated middleware execution. Refactor shared logic into pure functions instead.
- Consider SuperJSON Alternatives: If you do not need advanced type serialization (like Date objects or Maps), you can configure tRPC to use standard JSON parsing, which is faster.
Conclusion
tRPC provides an excellent balance of developer experience and performance out of the box. By understanding the underlying mechanics of request batching, serialization, and middleware execution, you can ensure your application remains highly responsive. Implementing database-level optimizations, caching strategies, and lean middleware will allow your tRPC APIs to scale effortlessly, handling high throughput while maintaining the end-to-end type safety that makes tRPC so powerful.