RateLimiter
RateLimiter consumes tokens for a string key using either a fixed-window
counter or a token-bucket, backed by a shared RateLimiterStore.
It is the building block for protecting external APIs, enforcing per-user or
per-tenant quotas, throttling job workers, and coordinating limits across many
fibers — or many processes when they share the Redis-backed store.
import { RateLimiter } from "effect/unstable/persistence"A single consume call decides whether the request is allowed and tells you what
to do when the limit is exceeded. You pick the behavior with onExceeded:
"fail"(the default) raises aRateLimiterErrorwhosereasonis aRateLimitExceeded."delay"never fails for overflow; it returns aConsumeResultwhosedelayyou are expected to honor yourself — or you let the accessors (makeWithRateLimiter/makeSleep) wait for you.
Throttling an effect
Section titled “Throttling an effect”The most common case: provide a store and the RateLimiter layer, then grab a
withLimiter function that wraps any effect so it is throttled. With
onExceeded: "delay" the wrapped effect is automatically delayed instead of
failing.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const program = Effect.gen(function* () { // Access the `withLimiter` function from the RateLimiter module const withLimiter = yield* RateLimiter.makeWithRateLimiter
// Apply a rate limiter to an effect yield* Effect.log("Making a request with rate limiting").pipe( withLimiter({ key: "some-key", limit: 10, onExceeded: "delay", window: "5 seconds", algorithm: "fixed-window" }) )})
// Provide an in-memory store + the RateLimiter serviceprogram.pipe( Effect.provide(RateLimiter.layer), Effect.provide(RateLimiter.layerStoreMemory), Effect.runFork)makeWithRateLimiter calls consume with your options, and if the returned
delay is non-zero it wraps the effect with Effect.delay. If the delay is
zero the effect runs immediately.
Sleeping until allowed
Section titled “Sleeping until allowed”When you do not want to wrap an arbitrary effect — you just want to pause the
current fiber until the next token is available — use makeSleep.
It consumes with onExceeded: "delay", sleeps for the returned delay (only if
non-zero), and yields the full ConsumeResult.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const program = Effect.gen(function* () { const sleep = yield* RateLimiter.makeSleep
// Only sleeps if the limit has been exceeded; returns ConsumeResult const result = yield* sleep({ key: "some-key", limit: 10, window: "5 seconds", algorithm: "fixed-window" })
yield* Effect.log(`remaining=${result.remaining}`)})Failing fast vs delaying
Section titled “Failing fast vs delaying”The same consume call behaves very differently depending on onExceeded. Fail
fast when you want to reject the caller (e.g. return 429 Too Many Requests);
delay when you want to smooth traffic and back off.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const failFast = Effect.gen(function* () { const limiter = yield* RateLimiter.RateLimiter
// Raises RateLimiterError (reason: RateLimitExceeded) once the budget is gone return yield* limiter.consume({ key: "user-42", limit: 5, window: "1 minute", onExceeded: "fail" })}).pipe( Effect.catchTag("RateLimiterError", (error) => // error.reason is RateLimitExceeded | RateLimitStoreError Effect.logWarning(`blocked: ${error.message}`) ))
const computeDelay = Effect.gen(function* () { const limiter = yield* RateLimiter.RateLimiter
// Never fails for overflow — returns the delay YOU must honor const result = yield* limiter.consume({ key: "user-42", limit: 5, window: "1 minute", onExceeded: "delay" })
// You are responsible for actually waiting `result.delay` yield* Effect.sleep(result.delay)})When tokens exceeds limit outright, the request can never succeed: fail
raises immediately with retryAfter equal to the window, and delay returns a
result with remaining: 0 and delay equal to the window.
Algorithms
Section titled “Algorithms”import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const burstyButSmooth = Effect.gen(function* () { const limiter = yield* RateLimiter.RateLimiter
// Allow bursts up to 100, refilling smoothly over the window return yield* limiter.consume({ key: "tenant-acme", limit: 100, window: "1 minute", algorithm: "token-bucket", onExceeded: "delay" })})Cross-process coordination
Section titled “Cross-process coordination”The in-memory store (layerStoreMemory) is process-local:
it lives in plain Maps and only coordinates fibers inside one runtime. To
enforce a limit across multiple processes or machines, use the Redis-backed store
(layerStoreRedis), which performs all counter updates inside
atomic Lua scripts so concurrent consumers cannot race.
import { Effect, Layer } from "effect"import { RateLimiter, Redis } from "effect/unstable/persistence"
// `Redis.Redis` is built from a `send` transport you supply (see the Redis page).declare const RedisLive: Layer.Layer<Redis.Redis>
const program = Effect.gen(function* () { const withLimiter = yield* RateLimiter.makeWithRateLimiter yield* Effect.log("shared across processes").pipe( withLimiter({ key: "global", limit: 1000, window: "1 minute" }) )}).pipe( Effect.provide(RateLimiter.layer), // Shared, atomic store coordinated through Redis Effect.provide(RateLimiter.layerStoreRedis({ prefix: "ratelimiter:" })), Effect.provide(RedisLive))Reference
Section titled “Reference”Model & service
Section titled “Model & service”RateLimiter (interface)
Section titled “RateLimiter (interface)”The service shape: a single consume method that takes a key, a limit, a
window, and optional algorithm / onExceeded / tokens, returning a
ConsumeResult or failing with RateLimiterError.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
declare const limiter: RateLimiter.RateLimiter
const result = limiter.consume({ key: "k", limit: 10, window: "1 minute", algorithm: "fixed-window", // default; or "token-bucket" onExceeded: "fail", // default; or "delay" tokens: 1 // default; how many tokens this call consumes})// => Effect.Effect<RateLimiter.ConsumeResult, RateLimiter.RateLimiterError>RateLimiter (service tag)
Section titled “RateLimiter (service tag)”The Context.Service tag used to access or provide the limiter. Yield it in a
generator to get the live RateLimiter.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const use = Effect.gen(function* () { const limiter = yield* RateLimiter.RateLimiter // => RateLimiter (requires RateLimiter in the environment) return limiter})ConsumeResult (interface)
Section titled “ConsumeResult (interface)”Metadata returned after a consume:
delay—Durationto wait before the next request underonExceeded: "delay";Duration.zerowhen the request is allowed immediately.limit— the configured maximum for the window.remaining— remaining requests in the current window (can be negative for token-bucket overflow).resetAfter— time until the limit fully resets.
import { Duration } from "effect"import type { RateLimiter } from "effect/unstable/persistence"
const allowed: RateLimiter.ConsumeResult = { delay: Duration.zero, limit: 10, remaining: 9, resetAfter: Duration.seconds(60)}// => { delay: 0ms, limit: 10, remaining: 9, resetAfter: 60s }Constructors & accessors
Section titled “Constructors & accessors”An Effect that builds a RateLimiter from the current RateLimiterStore. Use
it when you want the limiter value directly rather than via the layer.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const program = Effect.gen(function* () { const limiter = yield* RateLimiter.make // requires RateLimiterStore return yield* limiter.consume({ key: "k", limit: 5, window: "10 seconds" })}).pipe(Effect.provide(RateLimiter.layerStoreMemory))// => Effect<ConsumeResult, RateLimiterError>A Layer providing RateLimiter from a RateLimiterStore. Compose it with a
store layer to wire up the service.
import { Layer } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const Live = RateLimiter.layer.pipe( Layer.provide(RateLimiter.layerStoreMemory))// => Layer<RateLimiter, never, never>makeWithRateLimiter
Section titled “makeWithRateLimiter”An Effect yielding a withLimiter(options) function that returns an effect
transformer. The transformer delays the wrapped effect by ConsumeResult.delay
(or runs it immediately when the delay is zero).
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const program = Effect.gen(function* () { const withLimiter = yield* RateLimiter.makeWithRateLimiter return yield* Effect.succeed("work").pipe( withLimiter({ key: "k", limit: 10, window: "1 minute", onExceeded: "delay" }) )})// => the effect, automatically delayed when over the limitmakeSleep
Section titled “makeSleep”An Effect yielding a sleep(options) function. It consumes with
onExceeded: "delay", sleeps for the returned delay when non-zero, and yields the
ConsumeResult. (Note: sleep has no onExceeded option — it is always "delay".)
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const program = Effect.gen(function* () { const sleep = yield* RateLimiter.makeSleep const result = yield* sleep({ key: "k", limit: 10, window: "1 minute" }) return result.remaining})// => sleeps only when needed, then returns ConsumeResultErrors
Section titled “Errors”RateLimiterError
Section titled “RateLimiterError”The top-level error (a Schema.ErrorClass) raised by limiter operations. Its
_tag is "RateLimiterError", it carries a reason
(RateLimiterErrorReason), and its message proxies
reason.message.
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const handled = Effect.gen(function* () { const limiter = yield* RateLimiter.RateLimiter return yield* limiter.consume({ key: "k", limit: 1, window: "1 minute" })}).pipe( Effect.catchTag("RateLimiterError", (error) => { // error._tag === "RateLimiterError" // error.reason is RateLimitExceeded | RateLimitStoreError return Effect.logWarning(error.message) }))RateLimitExceeded
Section titled “RateLimitExceeded”The reason raised when a fail request is over budget (a Schema.ErrorClass,
_tag "RateLimitExceeded"). Fields: retryAfter (a Duration, encoded as
DurationFromMillis), key, limit, and remaining. Its message is
"Rate limit exceeded".
import { Duration } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const reason = new RateLimiter.RateLimitExceeded({ key: "user-42", retryAfter: Duration.seconds(30), limit: 5, remaining: 0})// => reason._tag === "RateLimitExceeded"; reason.message === "Rate limit exceeded"RateLimitStoreError
Section titled “RateLimitStoreError”The reason raised when the backing store fails (a Schema.ErrorClass, _tag
"RateLimitStoreError"). Fields: message and an optional cause. The Redis
store maps transport failures into this reason.
import { RateLimiter } from "effect/unstable/persistence"
const reason = new RateLimiter.RateLimitStoreError({ message: "Failed to execute fixedWindow rate limiting command"})// => reason._tag === "RateLimitStoreError"RateLimiterErrorReason
Section titled “RateLimiterErrorReason”The union type RateLimitExceeded | RateLimitStoreError, plus an exported
Schema.Union value of the same name for decoding/encoding error reasons.
import { Schema } from "effect"import { RateLimiter } from "effect/unstable/persistence"
// Value: Schema.Union([RateLimitExceeded, RateLimitStoreError])const decode = Schema.decodeUnknownSync(RateLimiter.RateLimiterErrorReason)// type RateLimiterErrorReason = RateLimitExceeded | RateLimitStoreErrorType IDs
Section titled “Type IDs”TypeId
Section titled “TypeId”The runtime brand for RateLimiter values: "~effect/persistence/RateLimiter".
Available both as a value and as a type.
import { RateLimiter } from "effect/unstable/persistence"
RateLimiter.TypeId // => "~effect/persistence/RateLimiter"ErrorTypeId
Section titled “ErrorTypeId”The runtime brand stamped on RateLimiterError instances:
"~@effect/experimental/RateLimiter/RateLimiterError". Available as a value and a
type.
import { RateLimiter } from "effect/unstable/persistence"
RateLimiter.ErrorTypeId // => "~@effect/experimental/RateLimiter/RateLimiterError"RateLimiterStore
Section titled “RateLimiterStore”The low-level Context.Service that the limiter delegates to. You normally use a
provided store layer rather than implementing this yourself, but the contract is:
fixedWindow({ key, tokens, refillRate, limit })returns[count, ttl]— the tokencountafter takingtokens, and the remaining TTL in milliseconds. Iflimitis provided and exceeded, the returnedcountis greater than the limit and the TTL is not updated.tokenBucket({ key, tokens, limit, refillRate, allowOverflow })returns the remaining token count after consumingtokens. WithallowOverflow: truethe count may drop below zero; otherwise a negative return only signals that the request exceeded availability — the persisted count is never pushed below zero.
import { Effect, Duration } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const probe = Effect.gen(function* () { const store = yield* RateLimiter.RateLimiterStore const [count, ttl] = yield* store.fixedWindow({ key: "k", tokens: 1, refillRate: Duration.seconds(6), limit: 10 }) return { count, ttl } // => e.g. { count: 1, ttl: 6000 }}).pipe(Effect.provide(RateLimiter.layerStoreMemory))layerStoreMemory
Section titled “layerStoreMemory”A Layer providing a process-local RateLimiterStore backed by in-memory Maps
(one for fixed-window counters, one for token buckets). Ideal for single-process
apps and tests; not shared across processes.
import { RateLimiter } from "effect/unstable/persistence"
RateLimiter.layerStoreMemory// => Layer<RateLimiterStore, never, never>makeStoreRedis
Section titled “makeStoreRedis”An Effect (requiring Redis.Redis) that builds a Redis-backed
RateLimiterStore using atomic Lua scripts. Optional prefix is prepended to
every key (default "ratelimiter:").
import { Effect } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const program = Effect.gen(function* () { const store = yield* RateLimiter.makeStoreRedis({ prefix: "rl:" }) return store // => RateLimiterStore (requires Redis.Redis)})layerStoreRedis
Section titled “layerStoreRedis”The Layer form of makeStoreRedis. Provides a Redis-backed RateLimiterStore
and requires Redis.Redis in its environment.
import { RateLimiter } from "effect/unstable/persistence"
RateLimiter.layerStoreRedis({ prefix: "ratelimiter:" })// => Layer<RateLimiterStore, never, Redis.Redis>layerStoreRedisConfig
Section titled “layerStoreRedisConfig”Provides the Redis store from a Config.Wrap of { prefix? }, so the prefix can
come from configuration. The resulting layer adds Config.ConfigError to its
error channel and still requires Redis.Redis.
import { Config } from "effect"import { RateLimiter } from "effect/unstable/persistence"
const StoreLive = RateLimiter.layerStoreRedisConfig({ prefix: Config.string("RATE_LIMIT_PREFIX").pipe(Config.withDefault("ratelimiter:"))})// => Layer<RateLimiterStore, Config.ConfigError, Redis.Redis>