Skip to content

tRPC v12 isn’t on npm yet. The @trpc12/* packages on this site can’t be installed until it’s released. Use v11 today

tRPC

ExperimentalRate limits, locks and durable calls might change or go away in any release. Stability

Rate limiting

experimental_rateLimit() counts calls with Effect's RateLimiter, in memory, Redis, Upstash or Durable Objects, and sets the RateLimit headers.

On this page

experimental_rateLimit() counts calls with Effect's RateLimiter (effect/persistence). The store is a layer on the handler: layerStoreMemory for one instance, layerStoreRedis for many.

import { RateLimiter } from 'effect/persistence';
import { experimental_rateLimit } from '@trpc12/trpc/middleware';

const login = t.procedure
  .use(
    experimental_rateLimit<{ ctx: { ip: string } }>({
      key: (opts) => `login:${opts.ctx.ip}`,
      limit: 5,
      window: '1 minute',
    }),
  )
  .mutation(/* … */);

createFetchHandler({
  router,
  layer: RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory)),
});

Past the limit it fails with experimental_RateLimited (TOO_MANY_REQUESTS, 429, data: { limit, retryAfter } in milliseconds), and it sets RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and Retry-After. onExceeded: 'delay' waits for capacity instead.

Stores

The store is Effect's RateLimiterStore, under RateLimiter.layer. A store that fails (the network, the database) is a defect: the call fails with a masked 500, not the caller's 429.

StoreFromCounts across
RateLimiter.layerStoreMemoryeffect/persistenceone process
RateLimiter.layerStoreRedis()effect/persistenceeverything sharing the Redis
experimental_upstashRateLimiterStore()@trpc12/trpc/middlewareeverything sharing the database
experimental_durableRateLimiterStore(binding)@trpc12/trpc/cloudflareevery Worker instance

layerStoreRedis() needs Effect's Redis service, e.g. NodeRedis.layer({ url }) from @effect/platform-node.

Upstash

experimental_upstashRateLimiterStore({ url, token }) is Effect's Redis store over Upstash's REST API, so it runs where TCP doesn't (Workers, edge functions). Each call is one fetch, two the first time a Lua script runs. experimental_upstashRedis({ url, token }) is the Redis service underneath, for anything else built on it (experimental_redisLocker()); its subscribe fails, since Upstash's REST API has no pub/sub.

import { import LayerLayer } from 'effect';
import { import RateLimiterRateLimiter } from 'effect/persistence';
import { function experimental_upstashRateLimiterStore(options: experimental_UpstashRateLimiterStoreOptions): Layer.Layer<RateLimiter.RateLimiterStore>experimental_upstashRateLimiterStore } from '@trpc12/trpc/middleware';

createFetchHandler<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}, undefined>(opts: FetchHandlerOptions<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}, undefined>): FetchHandler<undefined>
createFetchHandler
({
BaseHandlerOptions<{ health: Procedure<{ type: "query"; ctx: {}; meta: {}; input: undefined; output: string; errors: never; services: never; }>; }>.router: {
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
router
,
layer?: Layer.Layer<never, any, never> | undefinedlayer: import RateLimiterRateLimiter.const layer: Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>layer.Pipeable.pipe<Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>, Layer.Layer<RateLimiter.RateLimiter, never, never>>(this: Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>, ab: (_: Layer.Layer<RateLimiter.RateLimiter, never, RateLimiter.RateLimiterStore>) => Layer.Layer<RateLimiter.RateLimiter, never, never>): Layer.Layer<RateLimiter.RateLimiter, never, never> (+21 overloads)pipe( import LayerLayer.const provide: <never, never, RateLimiter.RateLimiterStore>(that: Layer.Layer<RateLimiter.RateLimiterStore, never, never>) => <RIn2, E2, ROut2>(self: Layer.Layer<ROut2, E2, RIn2>) => Layer.Layer<ROut2, E2, Exclude<RIn2, RateLimiter.RateLimiterStore>> (+3 overloads)provide( function experimental_upstashRateLimiterStore(options: experimental_UpstashRateLimiterStoreOptions): Layer.Layer<RateLimiter.RateLimiterStore>experimental_upstashRateLimiterStore({ experimental_UpstashOptions.url: stringurl:
const env: {
    UPSTASH_REDIS_REST_URL: string;
    UPSTASH_REDIS_REST_TOKEN: string;
}
env
.type UPSTASH_REDIS_REST_URL: stringUPSTASH_REDIS_REST_URL,
experimental_UpstashOptions.token: stringtoken:
const env: {
    UPSTASH_REDIS_REST_URL: string;
    UPSTASH_REDIS_REST_TOKEN: string;
}
env
.type UPSTASH_REDIS_REST_TOKEN: stringUPSTASH_REDIS_REST_TOKEN,
}), ), ), });

Cloudflare

experimental_durableRateLimiterStore(env.RATE_LIMITS) counts each key in its own Durable Object, with the object's clock and storage, so a limit holds across Worker instances and evictions, and each call can pass its own limit, window and algorithm. It's one subrequest per call. The object delegates to experimental_durableRateLimitObject(this.ctx), whose alarm deletes the counters once they expire (Cloudflare).

Cloudflare's Rate Limiting binding (env.LIMITER.limit({ key })) isn't a store. Its limit and period (10 or 60 seconds) are fixed in wrangler.jsonc, it counts per Cloudflare location and settles eventually, and it answers only { success }. So it can't take experimental_rateLimit()'s limit and window, run a token bucket, or fill in RateLimit-Remaining and Retry-After. To use it, call it from your own middleware and fail with experimental_RateLimited.

On the client

experimental_RateLimited is a typed error, so the client narrows on its code. retryLink({ retry, delayMs }) gets the error, so it can wait error.data.retryAfter (retryLink).

Credits

experimental_rateLimit() follows oRPC's rate limit helpers.