Skip to content

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:

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 service
program.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.

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}`)
})

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.

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"
})
})

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)
)

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>

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
})

Metadata returned after a consume:

  • delayDuration to wait before the next request under onExceeded: "delay"; Duration.zero when 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 }

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>

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 limit

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 ConsumeResult

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)
})
)

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"

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"

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 | RateLimitStoreError

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"

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"

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 token count after taking tokens, and the remaining TTL in milliseconds. If limit is provided and exceeded, the returned count is greater than the limit and the TTL is not updated.
  • tokenBucket({ key, tokens, limit, refillRate, allowOverflow }) returns the remaining token count after consuming tokens. With allowOverflow: true the 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))

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>

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)
})

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>

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>