Slow, unoptimized apps lead to frustration and churn. When building with the create-t3-app or tRPC, ensuring optimal performance often involves tackling ratelimiting. While Redis has long been a go-to for ratelimiting, there's a more performant product Unkey. By integrating Unkey with your tRPC endpoints, you can achieve fast and global consistent ratelimiting, directly enhancing the user experience.
Why Rate Limit?
Rate limiting is crucial for several reasons:
Prevent Abuse: Protect your API from malicious attacks like DoS or brute-force attempts.
Fair Usage: Ensure all users have equitable access to your resources, preventing a single user from monopolizing them.
Cost Control: For paid APIs, rate limits are essential for managing resource consumption and billing.
Performance Stability: Prevent your backend from being overwhelmed, maintaining consistent performance for all users.
The Unkey Advantage: Beyond Redis
Traditional Redis-based rate limiters, while functional, introduce an additional network hop. Every rate limit check requires a round trip to your Redis instance, adding latency. In high-throughput applications, this latency quickly accumulates, impacting perceived performance.Unkey, however, operates using a global network of servers. This architectural difference provides significant advantages:
Lower Latency: By placing rate limit checks closer to your users, Unkey drastically reduces the network overhead. This translates to near-instantaneous responses, making your application feel incredibly snappy.
Scalability Out-of-the-Box: Unkey is designed for scale. You don't need to manage Redis clusters or worry about scaling your rate limiting infrastructure. Unkey handles it all, effortlessly adapting to your traffic demands.
Simplified Development: Integrating Unkey is straightforward, especially within the tRPC ecosystem. This frees up your development team to focus on core features rather than managing complex infrastructure.
Reduced Infrastructure Costs: Eliminate the need to provision, manage, and scale Redis instances specifically for rate limiting. Unkey offers a cost-effective, managed solution.
Granular Control: Unkey provides flexible options for defining rate limits, allowing you to tailor them to specific endpoints, users or identifiers.
Integrating Unkey with tRPC in create-t3-app
Integrating Unkey with your tRPC endpoints within a create-t3-app project is remarkably simple. Here's a simplified conceptual approach:First, install the Unkey SDK:
npm install @unkey/ratelimit
# or
pnpm install @unkey/ratelimit
# or
yarn add @unkey/ratelimit
Next, initialize the Unkey client:
// src/server/unkey.ts
import { Ratelimit } from "@unkey/ratelimit";
const fallback = (identifier: string) => ({
identifier,
success: false,
limit: 0,
reset: 0,
remaining: 0,
});
export const limiter = ({
limit,
duration,
}: {
limit: number;
duration: number;
}) =>
new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
duration,
limit,
namespace: "waitlist",
timeout: {
ms: 3000,
fallback,
},
onError: (err, identifier) => {
console.error(`${identifier} - ${err.message}`);
return fallback(identifier);
},
});
Now, you can create a tRPC middleware to enforce rate limits:
// src/server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "~/server/auth";
import { limiter } from "../ratelimits/unkey";
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
session,
...opts,
};
};
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
export const rateLimitMiddleware = t.middleware(async ({ next, ctx, path }) => {
const customerId = ctx.session?.user?.id;
const trpcRoute = path;
// you could modify this to have different durations.
const ratelimit = limiter({
limit: 100,
duration: 60 * 1000,
namespace: trpcRoute,
});
if (!customerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required for rate limiting.",
});
}
const { remaining, reset, success } = await ratelimit.limit(customerId);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "You have exceeded your rate limit. Please try again later.",
// You can add more details from 'usage' to the error message if needed
});
}
return next({
ctx: {
...ctx,
// You might want to pass rate limit information to the context
rateLimitRemaining: remaining,
rateLimitReset: reset,
},
});
});
/**
* Protected (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
* the session is valid and guarantees `ctx.session.user` is not null.
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(rateLimitMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});
Finally, apply the rateLimitedProcedure to your tRPC routers:
// src/server/api/routers/example.ts
import { z } from "zod";
import { rateLimitedProcedure, publicProcedure, createTRPCRouter } from "../trpc";
export const exampleRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
// This procedure will be rate-limited
secretMessage: rateLimitedProcedure.query(() => {
return "You can see this secret message because you're within the rate limit!";
}),
});