API & Backend

Redis Rate Limiting in Node.js: A Production Guide

A three-step guide: Define the policy, Enforce the limit, Test failure modes.

A production Redis rate limiter should make its decision atomically, use a key that represents the intended policy subject, return enough information for standard HTTP headers, and have an explicit behavior when Redis is slow or unavailable. For burst-tolerant APIs, a token bucket is a practical algorithm: the bucket refills at a steady rate up to a fixed capacity, and each request consumes a token.

This tutorial implements the bucket in Lua so read, refill, decision, and write happen as one Redis operation. The Node.js layer validates configuration, constructs cluster-safe keys, adds Express middleware, and exposes observable outcomes. The design is suitable for per-user or per-API-key application limits; network-edge abuse prevention still belongs at a gateway, load balancer, or dedicated protection layer.

Translate product policy into an algorithm

Begin with a policy statement, not Redis commands. For example: “Each API key may burst to 20 requests and then sustain five requests per second.” That statement yields:

  • Capacity: 20 tokens.
  • Refill rate: 5 tokens per second.
  • Cost: 1 token for an ordinary request.
  • Subject: authenticated API key ID.
  • Scope: a specific endpoint group or product plan.

The capacity controls the maximum immediate burst. The refill rate controls the long-term average. A client idle for four seconds returns to a full 20-token bucket, not 40 tokens, because capacity caps accumulation.

Different algorithms produce different behavior. A fixed window is simple but allows a burst at the boundary. A sliding log is precise but stores one entry per request. A sliding counter approximates a rolling window with low memory. Redis provides an official rate-limiting algorithm comparison that explains these trade-offs.

Document the policy in the API reference and plan limits. Our API documentation guide explains how error semantics affect integration, while the developer portal checklist covers discoverability and troubleshooting.

Define the Redis state and key

Store two fields in one Redis hash:

  • tokens: the current possibly fractional token count.
  • last_refill_ms: the timestamp used for the last calculation.

Use one key per policy subject and scope:

ratelimit:{api-key-9f31}:write-operations:v1

The braces are a Redis Cluster hash tag. Redis hashes only the text inside braces when assigning a slot, which helps when a script uses multiple related keys. This implementation uses one key, but a stable tag leaves room for a second short-term abuse counter without cross-slot errors.

Never use the raw secret API key. Use the server-side key ID or a one-way identifier that is safe in diagnostics. Bound subject length before creating a Redis key. Tenant IDs and user IDs may still be sensitive, so apply your data policy to logs and monitoring.

Version the policy suffix. If you radically change capacity semantics or key dimensions, v2 avoids reinterpreting old state. Do not add version numbers casually because each change temporarily doubles key cardinality.

Implement the atomic token bucket in Lua

Redis executes a Lua script atomically relative to other commands. That prevents two concurrent requests from reading the same token count and both being allowed. The official Redis guide highlights this time-of-check/time-of-use problem and uses scripts for the same reason.

-- KEYS[1] bucket key
-- ARGV[1] now in milliseconds
-- ARGV[2] capacity
-- ARGV[3] refill tokens per millisecond
-- ARGV[4] requested token cost
-- ARGV[5] TTL in milliseconds

local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refill_per_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl_ms = tonumber(ARGV[5])

local state = redis.call('HMGET', key, 'tokens', 'last_refill_ms')
local tokens = tonumber(state[1])
local last_refill = tonumber(state[2])

if tokens == nil then tokens = capacity end
if last_refill == nil then last_refill = now end

local effective_now = math.max(now, last_refill)
local elapsed = effective_now - last_refill
tokens = math.min(capacity, tokens + (elapsed * refill_per_ms))

local allowed = 0
local retry_after_ms = 0

if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retry_after_ms = math.ceil((cost - tokens) / refill_per_ms) + (effective_now - now)
end

redis.call('HSET', key, 'tokens', tokens, 'last_refill_ms', effective_now)
redis.call('PEXPIRE', key, ttl_ms + (effective_now - now))

return { allowed, tostring(tokens), retry_after_ms }

The script keeps its stored timestamp monotonic when a caller’s clock moves backward. Merely clamping elapsed time to zero and then storing the earlier timestamp is insufficient: the next request can refill the same interval twice. Retry delay and expiry also include the clock difference. For stronger consistency across application hosts, obtain time inside Redis with TIME; passing time makes isolated tests deterministic but still requires bounded, synchronized caller clocks. Reject implausible timestamps at the application boundary.

The state records effective_now even for a denied request. That preserves the fractional refill calculated at that moment without moving the stored timestamp backward. Verify the math under repeated denials and interleaved callers, not only a steadily advancing test clock.

The TTL should exceed the time required for an empty bucket to refill completely. Idle full buckets do not need permanent storage. Add a safety margin and cap the value to avoid invalid configuration.

Call the script from Node.js

Load the script and use EVALSHA in steady state. Redis clients may provide a script-definition helper that loads and retries automatically. The interface below keeps the expected behavior clear.

type LimitDecision = {
  allowed: boolean;
  remaining: number;
  retryAfterMs: number;
};

export async function consumeToken(input: {
  redis: RedisClient;
  subject: string;
  capacity: number;
  refillPerSecond: number;
  cost?: number;
  nowMs?: number;
}): Promise<LimitDecision> {
  validatePolicy(input);
  const cost = input.cost ?? 1;
  const refillPerMs = input.refillPerSecond / 1000;
  const fullRefillMs = Math.ceil(input.capacity / refillPerMs);
  const ttlMs = fullRefillMs * 2;
  if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > 86_400_000) {
    throw new RangeError('Policy refill time exceeds this limiter’s supported TTL');
  }
  const key = `ratelimit:{${safeSubject(input.subject)}}:default:v1`;

  const result = await input.redis.eval(TOKEN_BUCKET_SCRIPT, {
    keys: [key],
    arguments: [
      String(input.nowMs ?? Date.now()),
      String(input.capacity),
      String(refillPerMs),
      String(cost),
      String(ttlMs)
    ]
  });

  const [allowed, remaining, retryAfterMs] = parseScriptResult(result);
  return {
    allowed: allowed === 1,
    remaining: Math.max(0, Math.floor(Number(remaining))),
    retryAfterMs: Number(retryAfterMs)
  };
}

Validate that capacity, refill rate, and cost are finite positive numbers and that cost does not exceed capacity. Reject a subject outside a narrow character and length policy. Configuration errors should fail during startup, not on the first customer request. The one-day TTL here is a supported-policy boundary, not a clamp: silently expiring a bucket before it can refill would recreate it at full capacity and permit extra requests. Increase the supported boundary deliberately if a slower refill is required.

Lua numeric values use floating-point representation. Fractional refills are convenient, but high precision is not guaranteed. For very strict accounting, represent tokens in integer micro-units and calculate with documented rounding.

Once the script is stable, load it at startup and call by SHA. Handle NOSCRIPT by loading and retrying once, which is necessary after failover or cache flush. Bound each Redis call with a short deadline.

Add Express rate-limit middleware

Resolve identity only after authentication. Limiting by an unverified header lets a caller select someone else’s bucket. Fall back to an IP policy only when proxy trust is configured correctly.

import type { NextFunction, Request, Response } from 'express';

export function rateLimit(options: RateLimitOptions) {
  return async function middleware(
    request: Request,
    response: Response,
    next: NextFunction
  ) {
    const subject = request.auth?.apiKeyId;
    if (!subject) return response.status(401).json({ error: 'unauthorized' });

    try {
      const decision = await consumeToken({
        redis: options.redis,
        subject,
        capacity: options.capacity,
        refillPerSecond: options.refillPerSecond
      });

      response.setHeader('RateLimit-Limit', String(options.capacity));
      response.setHeader('RateLimit-Remaining', String(decision.remaining));

      if (!decision.allowed) {
        const retryAfterSeconds = Math.max(1, Math.ceil(decision.retryAfterMs / 1000));
        response.setHeader('Retry-After', String(retryAfterSeconds));
        return response.status(429).json({
          error: {
            code: 'rate_limit_exceeded',
            message: 'Too many requests. Retry later.'
          }
        });
      }

      next();
    } catch (error) {
      options.onStoreError(error, request, response, next);
    }
  };
}

The RateLimit-Limit and RateLimit-Remaining fields above are application-defined compatibility headers, not fields defined by RFC 9333. Document their units and semantics explicitly: in this example, capacity and remaining values are tokens, not a fixed-window request allowance. Clients with variable request costs must not interpret remaining tokens as guaranteed remaining requests. For standards-based retry guidance, RFC 6585 defines 429, and RFC 9110 defines Retry-After as an HTTP date or a non-negative integer number of seconds. The example rounds up to avoid recommending a retry before enough tokens accrue.

Place middleware after cheap authentication but before expensive body parsing or downstream work where possible. If request cost differs significantly, charge by operation. A bulk export may cost more tokens than a status read, but keep cost categories predictable and documented.

Decide fail-open or fail-closed behavior

When Redis is unavailable, the middleware cannot know the distributed count. The correct fallback depends on the protected operation.

Fail open when availability matters more than temporary overuse and another layer still protects infrastructure. A read-only product API might permit requests during a short Redis outage, record a metric, and rely on gateway limits.

Fail closed when the limit is a security or financial control, such as password attempts, expensive provisioning, or paid quota enforcement. Return a service error rather than pretending the client exceeded its normal rate. A 503 communicates dependency failure better than a false 429.

A local emergency bucket can reduce impact, but it is per-process and resets on restart. Document that reduced guarantee. Never catch every Redis error and silently allow traffic; operators need a visible signal that a control is degraded.

Separate product quota from protective rate limiting. Quota may require durable accounting, reconciliation, and customer-visible usage. A Redis bucket is excellent for smoothing traffic but may not be the billing ledger.

Handle proxies, IPs, and multiple dimensions

Authenticated identifiers are usually the fairest primary key. IP limits can help unauthenticated endpoints, but NAT can group many legitimate users while attackers can distribute traffic. Treat IP as one signal.

Configure Express trust proxy only for known proxy topology. Otherwise a forged X-Forwarded-For header can bypass or target limits. At the network edge, use the load balancer’s verified client address mechanism.

Layer policies when necessary: a per-key bucket, a tenant-wide bucket, and a global emergency limit. A multi-key Lua script in Redis Cluster must keep all keys in the same hash slot, which may conflict with independent subjects. It can be simpler to make separate calls, accept a carefully designed shared tag, or enforce the global dimension at a gateway.

Avoid unbounded key creation from arbitrary usernames on a public login form. Normalize identifiers and pair Redis TTL with upstream bot controls. Monitor keyspace cardinality and memory.

Test timing and concurrency deterministically

Test against a real local Redis instance because mocks rarely reproduce script atomicity or numeric conversion. Pass nowMs explicitly to advance time without sleeping.

Core cases include:

  • A new bucket begins at capacity and allows a request.
  • Capacity requests succeed and the next is denied.
  • Partial elapsed time refills the expected fraction.
  • Tokens never exceed capacity after a long idle period.
  • Cost greater than remaining tokens returns a correct delay.
  • Backward time does not reduce the bucket.
  • The key expires after inactivity.
  • Invalid configuration fails before a Redis call.

Launch many concurrent consumes against one key and assert that allowed decisions never exceed capacity at the same timestamp. This is the test that distinguishes an atomic script from separate HGET and HSET calls.

Test NOSCRIPT, timeouts, connection loss, failover, and the chosen fail policy. Test middleware response code, body, headers, and next calls. Ensure logs contain the policy name and error category without the raw credential.

Monitor the limiter in production

Count allowed, denied, and store-error decisions with bounded labels such as policy, route group, plan, and outcome. Do not label metrics by API key, user, IP, or Redis key. Track Redis latency and timeout rate separately from normal denials.

Watch denial ratio, not just denial count. Traffic growth can raise both allowed and denied totals. Alert on unexpected changes by route or plan and on any sustained control degradation.

Sample detailed denial logs to avoid amplifying an attack into a logging incident. Include a hashed or internal subject ID only when your incident process needs it and policy permits it. Keep retention proportionate.

Capacity planning should include peak request rate, number of active subjects, script CPU, memory per key, network latency, cluster topology, and expiry behavior. Load test with realistic key distribution; one hot key and millions of cold keys stress different parts of Redis.

Document limits for API consumers

State the subject, scope, capacity or practical burst, sustained rate, relevant headers, 429 response shape, and retry guidance. If plans differ, show a table. If the policy can change dynamically, avoid promising an exact algorithm when the user only needs behavioral guarantees.

SDKs should surface 429 as a typed error and parse Retry-After. Automatic retries need jitter, a maximum attempt count, cancellation, and awareness of non-idempotent operations. Retrying every rejected request at the same second creates another burst.

Explain whether limits are shared across regions, endpoints, tokens, or team members. “100 requests per minute” is incomplete when the reader cannot tell what owns the counter.

Keep documentation synchronized with gateway and application configuration. A limit that exists only in code surprises customers; a documented limit that is no longer enforced produces unreliable capacity assumptions.

Prevent common bypasses and policy mistakes

Apply limits to every route variant that reaches the protected operation, including alternate API versions, batch endpoints, and GraphQL or RPC gateways. A middleware attached only to the documented path does not protect an internal alias. Centralize policy mapping and test route coverage.

Do not reset a bucket when an API key rotates if both keys represent the same account and quota. Decide whether the subject is a credential, user, tenant, installation, IP, or combination. Attackers will use whichever dimension the product leaves cheapest.

Normalize identifiers before key construction and reject control characters. Keep the server-selected policy authoritative; a client-provided plan header must not increase capacity. When authorization changes, invalidate cached plan data promptly enough for the risk.

HTTP 429 Too Many Requests is defined in RFC 6585. Use it for an actual request-rate decision. Use 503 for limiter infrastructure failure when failing closed, so clients and operators can distinguish policy from outage.

Frequently asked questions

Why use Lua for Redis rate limiting?

The algorithm must read state, calculate refill, make a decision, and update state without another request interleaving. Redis runs the Lua script atomically, avoiding the race created by separate client commands.

Is token bucket better than sliding window?

It is better when controlled bursts are desirable and the long-term average matters. Use a sliding window when the number of requests in every rolling period must be constrained more directly. Choose based on product behavior, not popularity.

Should a rate limiter fail open?

Only when temporary overuse is less harmful than blocking legitimate traffic and another protection layer exists. Security-sensitive or expensive operations may need fail-closed behavior. Make the choice explicit and observable.

Can Redis rate limiting enforce billing quotas?

It can enforce a fast protective approximation, but billing usually needs a durable source of truth, auditability, adjustments, and reconciliation. Keep traffic shaping and commercial accounting separate unless the system is designed for both.

Make the limit predictable

A dependable rate limiter combines clear product policy, an atomic algorithm, safe identity, bounded storage, an intentional outage mode, deterministic tests, and precise consumer documentation. Redis supplies fast shared state; your design supplies the guarantee.

If your API needs a rate-limit reference, integration guide, or operational tutorial, explore our API documentation service. Limits are easiest to respect when the contract explains exactly who is limited, how clients recover, and what failures mean.

Start a conversation

Ready to improve your developer documentation?

Tell us what developers need to accomplish and where the current experience falls short.

Expect a reply within one business day.