Sliding Window Rate Limiting with Durable Objects

This guide is part of Rate Limiting and Abuse Prevention at the Edge. It replaces the fixed-window counter most teams start with — the one that quietly allows twice your stated limit — with a sliding window enforced by a single-threaded Durable Object, and deals with the three things that make that design fail in production: hot objects, cross-region latency, and the object being unreachable.

A rate limit is a promise about a rate, and a fixed window does not keep it. The gap between “100 requests per minute” and what a fixed window actually enforces is not a rounding error; it is a factor of two, reliably reproducible, and trivially exploitable by anyone who notices.

The problem

Your limit is 100 requests per minute per API key. You implement it the obvious way: a counter keyed by apiKey:minute, incremented on each request, reset when the minute rolls over. Traffic looks fine on the dashboard. Then a customer’s retry storm takes down an upstream, and when you reconstruct the timeline you find they sent 200 requests inside a single second — while your limiter reported no violations at all.

They did nothing clever. They sent 100 requests at 12:00:59.5 and 100 more at 12:01:00.1. Two different windows, each within limit, 200 requests inside 600 milliseconds.

Root cause: a fixed window measures the calendar, not the rate

A fixed window partitions time into buckets aligned to a clock boundary and counts within each bucket independently. Nothing carries across the boundary, so any two adjacent buckets can each be filled to the limit with all the traffic pressed against the seam. The observable burst rate is 2 × limit over a span approaching zero, and it recurs on every boundary — which, because boundaries are clock-aligned and therefore identical for every client, means all your clients can burst simultaneously.

A sliding log fixes it exactly. Instead of a counter per bucket, keep the timestamp of every request in the trailing window; to decide, drop everything older than now - windowMs and count what remains. Because the window moves with the request rather than with the clock, there is no seam to exploit. The cost is memory proportional to the limit, which is why you cap the retained log.

Both need one more property that a distributed edge does not give you for free: a single authoritative counter. Two PoPs each running their own counter enforce 2 × limit between them, which is the same failure you were trying to eliminate. Durable Objects exist to supply exactly that — a named, single-threaded, globally unique instance with transactional storage, so increments cannot race. That guarantee is the reason to accept the network hop it costs.

Fixed window boundary burst versus a sliding window The upper timeline shows one hundred requests just before the minute boundary and one hundred just after, both allowed by a fixed window. The lower timeline shows the same traffic evaluated against a window that moves with each request, where the second batch is rejected. Fixed window bucket 12:00 count 100 of 100 bucket 12:01 count 100 of 100 200 in 0.6 s, allowed Sliding window 100 requests recorded log trimmed to now minus 60 s next 100 evaluated window still holds the first 100 rejected with 429 clock aligned request aligned
The boundary is the same instant for every client, so a fixed window does not merely allow one burst — it synchronises everyone's.

Step 1 — Write the Durable Object

The object holds the log in memory for speed and persists it so a restart does not hand every client a free window. Trimming happens on read, and an alarm reclaims storage for keys that have gone quiet.

// src/rate-limiter.ts
interface DecisionRequest {
  limit: number;
  windowMs: number;
  cost?: number;
}

export interface Decision {
  allowed: boolean;
  remaining: number;
  resetMs: number;
  retryAfterMs: number;
}

const STORAGE_KEY = 'log';
const IDLE_CLEANUP_MS = 5 * 60_000;

export class SlidingWindowLimiter {
  private log: number[] = [];
  private loaded = false;

  constructor(private readonly state: DurableObjectState) {
    // blockConcurrencyWhile guarantees no request is served with an
    // empty log while the first read from storage is still in flight.
    this.state.blockConcurrencyWhile(async () => {
      this.log = (await this.state.storage.get<number[]>(STORAGE_KEY)) ?? [];
      this.loaded = true;
    });
  }

  async fetch(request: Request): Promise<Response> {
    const { limit, windowMs, cost = 1 } = (await request.json()) as DecisionRequest;
    const now = Date.now();
    const cutoff = now - windowMs;

    // Trim on read. The log is sorted ascending, so one scan from the front.
    let drop = 0;
    while (drop < this.log.length && this.log[drop] <= cutoff) drop += 1;
    if (drop > 0) this.log = this.log.slice(drop);

    const used = this.log.length;
    const allowed = used + cost <= limit;

    if (allowed) {
      for (let i = 0; i < cost; i++) this.log.push(now);
      // A single put per decision. The DO is single-threaded, so this
      // cannot interleave with another decision for the same key.
      await this.state.storage.put(STORAGE_KEY, this.log);
    }

    // Oldest entry decides when capacity frees up.
    const oldest = this.log[0] ?? now;
    const resetMs = Math.max(0, oldest + windowMs - now);

    await this.state.storage.setAlarm(now + windowMs + IDLE_CLEANUP_MS);

    return Response.json({
      allowed,
      remaining: Math.max(0, limit - this.log.length),
      resetMs,
      retryAfterMs: allowed ? 0 : Math.max(1, resetMs),
    } satisfies Decision);
  }

  /** Reclaims storage for keys that stopped sending traffic. */
  async alarm(): Promise<void> {
    const now = Date.now();
    // Anything older than the longest window we serve is dead weight.
    this.log = this.log.filter((t) => t > now - 60 * 60_000);
    if (this.log.length === 0) {
      await this.state.storage.deleteAll();   // object becomes free to evict
      return;
    }
    await this.state.storage.put(STORAGE_KEY, this.log);
    await this.state.storage.setAlarm(now + IDLE_CLEANUP_MS);
  }
}

The alarm is not an optimization detail. Without it, every API key that ever made one request leaves a storage entry behind forever, and you pay for storage proportional to your all-time key cardinality rather than your active cardinality. The alarm turns that into a bounded working set.

Note also that the log is only appended to when the request is allowed. Recording rejected requests would mean a client hammering a closed door extends its own penalty indefinitely — occasionally what you want for abuse, never what you want for a well-behaved client with an aggressive retry loop.

Step 2 — Bound memory with a counter approximation

A sliding log costs one timestamp per allowed request in the window. At a limit of 100 that is trivial. At a limit of 50,000 per minute it is not, and the JSON serialization of that array on every decision becomes the bottleneck.

For high limits, use the weighted two-bucket approximation: keep a count for the current fixed bucket and the previous one, and estimate the sliding count by weighting the previous bucket by how much of it still overlaps the window.

interface Buckets { current: number; previous: number; bucketStart: number }

function slidingEstimate(b: Buckets, now: number, windowMs: number): number {
  const elapsed = now - b.bucketStart;
  const overlap = Math.max(0, 1 - elapsed / windowMs);
  return b.previous * overlap + b.current;
}

At the boundary, overlap is 1 and the previous bucket counts in full, so the double-burst is impossible; a third of the way in, it counts two thirds. The estimate can be off by a few percent for pathological traffic shapes, and it uses two integers instead of an array of any size. Use the exact log when the limit is small and precision matters — payment endpoints, login attempts — and the approximation when the limit is large and throughput matters.

Step 3 — Shard the key so one object is not a bottleneck

A Durable Object is single-threaded by design. Every request for a given name queues behind the previous one, which is what makes the count correct and also what makes a popular key a serialization point. One object handles a few thousand requests per second at best; a limiter for an unauthenticated public endpoint keyed on a single global name will melt long before that.

The fix is to shard: split the limit across N objects and route each request to one deterministically.

// src/shard.ts
export async function shardName(key: string, shards: number): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));
  const n = new DataView(digest).getUint32(0) % shards;
  return `${key}#${n}`;
}

/** Shard count scaled to how hot a key is expected to be. */
export function shardsFor(key: string): number {
  if (key.startsWith('ip:')) return 1;      // per-IP keys are naturally spread
  if (key.startsWith('global:')) return 16; // one shared key, high volume
  return 4;                                  // per-tenant keys
}

Two consequences you must accept. Each shard enforces limit / shards, so the aggregate limit is only approximately right — an unlucky hash distribution lets one client’s traffic concentrate on a shard and be limited early. And per-IP keys should usually not be sharded at all: the key space is already wide, so sharding multiplies your object count without reducing any single object’s load. Shard the keys that are hot, not the keys that are numerous.

Sharding a hot key across Durable Objects Edge requests carrying a shared key are hashed and routed to one of four Durable Object shards. Each shard enforces a quarter of the total limit, so no single object serializes all traffic. Edge isolate key = global:search SHA-256 mod 4 deterministic shard #0 — 250/min shard #1 — 250/min shard #2 — 250/min shard #3 — 250/min aggregate 1000/min, approximate not exact single-threaded single-threaded single-threaded single-threaded
Sharding trades exactness for throughput; the aggregate limit becomes approximate, which is almost always the right trade for a hot key.

Step 4 — Call it from middleware, and survive it being unreachable

A Durable Object lives in exactly one location. If the object was created by a request from Frankfurt and the current request lands in São Paulo, the decision costs a round trip across the Atlantic — typically 150 to 220 ms, which can be several times the rest of your request budget.

That cost has to be managed, not ignored. Set a timeout well below your latency budget, and decide in advance what happens when it expires.

// middleware.ts
import { shardName, shardsFor } from './src/shard';
import type { Decision } from './src/rate-limiter';

interface Env { LIMITER: DurableObjectNamespace }

const LIMIT = 100;
const WINDOW_MS = 60_000;
const DECISION_TIMEOUT_MS = 120;

async function decide(env: Env, key: string): Promise<Decision | null> {
  const name = await shardName(key, shardsFor(key));
  const stub = env.LIMITER.get(env.LIMITER.idFromName(name));

  const abort = new AbortController();
  const timer = setTimeout(() => abort.abort(), DECISION_TIMEOUT_MS);
  try {
    const res = await stub.fetch('https://limiter/decide', {
      method: 'POST',
      body: JSON.stringify({ limit: Math.ceil(LIMIT / shardsFor(key)), windowMs: WINDOW_MS }),
      signal: abort.signal,
    });
    return res.ok ? ((await res.json()) as Decision) : null;
  } catch {
    return null;                      // timeout, transport failure, or eviction
  } finally {
    clearTimeout(timer);
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const key = `tenant:${request.headers.get('x-api-key') ?? 'anon'}`;
    const decision = await decide(env, key);

    if (decision === null) {
      // Fail open for ordinary traffic: a limiter outage must not become
      // a site outage. Log it loudly so the gap is visible.
      console.log(JSON.stringify({ event: 'ratelimit.unavailable', key }));
      return withHeaders(await fetch(request), { 'x-ratelimit-mode': 'degraded' });
    }

    if (!decision.allowed) {
      return new Response('Too Many Requests', {
        status: 429,
        headers: {
          'retry-after': String(Math.ceil(decision.retryAfterMs / 1000)),
          'x-ratelimit-limit': String(LIMIT),
          'x-ratelimit-remaining': '0',
          'cache-control': 'no-store',
        },
      });
    }

    return withHeaders(await fetch(request), {
      'x-ratelimit-limit': String(LIMIT),
      'x-ratelimit-remaining': String(decision.remaining),
    });
  },
};

function withHeaders(res: Response, extra: Record<string, string>): Response {
  const headers = new Headers(res.headers);
  for (const [k, v] of Object.entries(extra)) headers.set(k, v);
  return new Response(res.body, { status: res.status, headers });
}

Fail open versus fail closed is a policy decision, not a technical one, and it should differ per route. A product listing should fail open — nobody thanks you for a 503 because a counter was slow. A password-reset endpoint or a payment mutation should fail closed, because an unmetered login endpoint during a limiter outage is precisely when credential stuffing succeeds. Encode that per route rather than globally.

Where you can, take the DO out of the hot path entirely for the common case: a cheap in-isolate check that rejects clients already known to be over their limit avoids the hop for the traffic that needs limiting most. Pair this with the KV-backed approach in per-IP rate limiting with Cloudflare KV as a first-tier filter, and reserve the Durable Object for keys that are actually near the threshold.

Degradation decision tree A request first consults an in-isolate hint, then calls the Durable Object with a timeout. On timeout the path branches by route sensitivity into fail open with a degraded header or fail closed with a 429. Request isolate hint known over limit? 429, no hop yes DO decide() timeout 120 ms no allowed, forward denied, 429 + Retry-After timeout or error branch by route ordinary route: fail open auth or payment: fail closed
The only wrong answer on timeout is a single global policy; sensitivity to an unmetered window differs enormously between routes.

Step 5 — Declare the binding

# wrangler.toml
name = "edge-rate-limiter"
main = "src/index.ts"
compatibility_date = "2026-01-15"

[[durable_objects.bindings]]
name = "LIMITER"
class_name = "SlidingWindowLimiter"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["SlidingWindowLimiter"]

[placement]
mode = "smart"

placement = "smart" lets the platform co-locate the Worker with the objects it talks to most, which materially reduces the cross-region hop for workloads whose traffic is regionally concentrated. It is not a substitute for a timeout — it shortens the common case, it does not remove the tail.

Local versus production divergence

Behaviour Local dev Edge production
DO round trip Sub-millisecond, in-process under wrangler dev 5 ms same-region to 220 ms cross-region
Object placement Single local instance Created near the first requester and stays there
Contention One client, no queueing Requests for one name serialize; a hot key queues
Alarms Fire, but the process is short-lived Fire reliably across restarts and evictions
Storage persistence Reset when you clear local state Durable across restarts and code deploys
Clock Your machine Date.now() in a DO advances only on IO, which is deliberate
Timeout path Effectively never exercised Exercised regularly under load

The first and last rows together explain why degradation code is almost always untested. Locally the call never times out, so the fail-open branch never runs and its bugs stay hidden until the day the limiter is genuinely slow. Test that branch explicitly.

The clock row is a real behavioural difference worth internalizing: inside a Durable Object, Date.now() is frozen between IO operations to make execution deterministic. Your trim logic reads the same now for the whole decision, which is what you want, but a loop that expects time to advance without awaiting anything will spin forever.

Validating with Vitest

Drive the object directly with a fake storage and a controllable clock. No network, no wrangler, fully deterministic.

// rate-limiter.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SlidingWindowLimiter, type Decision } from './src/rate-limiter';

function fakeState() {
  const map = new Map<string, unknown>();
  let alarmAt: number | null = null;
  return {
    storage: {
      get: async <T>(k: string) => map.get(k) as T | undefined,
      put: async (k: string, v: unknown) => void map.set(k, v),
      deleteAll: async () => void map.clear(),
      setAlarm: async (t: number) => void (alarmAt = t),
      getAlarm: async () => alarmAt,
    },
    blockConcurrencyWhile: async (fn: () => Promise<void>) => fn(),
    _map: map,
    _alarmAt: () => alarmAt,
  };
}

async function hit(limiter: SlidingWindowLimiter, limit = 3, windowMs = 1000): Promise<Decision> {
  const res = await limiter.fetch(
    new Request('https://limiter/decide', {
      method: 'POST',
      body: JSON.stringify({ limit, windowMs }),
    }),
  );
  return (await res.json()) as Decision;
}

describe('SlidingWindowLimiter', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-07-30T12:00:00.000Z'));
  });

  it('allows up to the limit and then denies', async () => {
    const limiter = new SlidingWindowLimiter(fakeState() as never);
    expect((await hit(limiter)).allowed).toBe(true);
    expect((await hit(limiter)).allowed).toBe(true);
    expect((await hit(limiter)).allowed).toBe(true);
    const fourth = await hit(limiter);
    expect(fourth.allowed).toBe(false);
    expect(fourth.remaining).toBe(0);
  });

  it('does not permit a double burst across a clock boundary', async () => {
    const limiter = new SlidingWindowLimiter(fakeState() as never);
    vi.setSystemTime(new Date('2026-07-30T12:00:59.500Z'));
    for (let i = 0; i < 3; i++) expect((await hit(limiter)).allowed).toBe(true);

    // 600 ms later — a new minute, but the same sliding window.
    vi.setSystemTime(new Date('2026-07-30T12:01:00.100Z'));
    expect((await hit(limiter)).allowed).toBe(false);
  });

  it('recovers capacity as the window slides past the oldest entry', async () => {
    const limiter = new SlidingWindowLimiter(fakeState() as never);
    for (let i = 0; i < 3; i++) await hit(limiter);
    vi.advanceTimersByTime(1001);
    vi.setSystemTime(Date.now() + 1001);
    expect((await hit(limiter)).allowed).toBe(true);
  });

  it('does not count denied requests against the client', async () => {
    const limiter = new SlidingWindowLimiter(fakeState() as never);
    for (let i = 0; i < 3; i++) await hit(limiter);
    for (let i = 0; i < 10; i++) await hit(limiter);   // all denied
    vi.setSystemTime(Date.now() + 1001);
    expect((await hit(limiter)).allowed).toBe(true);   // penalty did not extend
  });

  it('reports a retry-after that matches when capacity returns', async () => {
    const limiter = new SlidingWindowLimiter(fakeState() as never);
    for (let i = 0; i < 3; i++) await hit(limiter);
    const denied = await hit(limiter);
    expect(denied.retryAfterMs).toBeGreaterThan(0);
    expect(denied.retryAfterMs).toBeLessThanOrEqual(1000);
  });

  it('clears storage for an idle key on alarm', async () => {
    const state = fakeState();
    const limiter = new SlidingWindowLimiter(state as never);
    await hit(limiter);
    vi.setSystemTime(Date.now() + 2 * 60 * 60_000);
    await limiter.alarm();
    expect(state._map.size).toBe(0);
  });

  it('restores its log from storage after a restart', async () => {
    const state = fakeState();
    const first = new SlidingWindowLimiter(state as never);
    for (let i = 0; i < 3; i++) await hit(first);

    const revived = new SlidingWindowLimiter(state as never);
    expect((await hit(revived)).allowed).toBe(false);   // no free window on restart
  });
});

The boundary-burst test is the whole point of the page expressed as an assertion: it fails against a fixed-window implementation and passes against this one. Keep it, and keep the restart test, because an in-memory-only limiter passes every other test in this file while handing every client a fresh allowance each time an object is evicted.

Common pitfalls and resolutions

Clients get a free window at unpredictable intervals — the log lives only in memory. Durable Objects are evicted when idle; persist on write and reload in blockConcurrencyWhile.

A hot key adds hundreds of milliseconds under load — every request for one object name serializes. Shard the key and divide the limit, or move the common case to a cheaper first-tier check.

Latency is fine in Europe and terrible in Asia — the object was created by a European request and lives there. Enable smart placement, key per-region where the semantics allow it, and always set a timeout.

Storage costs grow without bound — no alarm-driven cleanup, so every key ever seen retains an entry. Set an alarm on each decision and delete all storage for a log that has emptied.

A limiter outage became a site outage — the code fails closed everywhere. Choose per route: fail open for ordinary traffic, fail closed for authentication and payments.

Clients retry immediately and stay blocked — no Retry-After header, so back-off is guesswork. Derive it from the oldest entry in the window and return it in seconds.

The aggregate limit is wrong after sharding — each shard enforces the full limit instead of its slice. Divide the limit by the shard count where you construct the request.

Production deployment checklist

Frequently Asked Questions

Why does a fixed window allow twice my stated limit?

Because it counts within clock-aligned buckets that share no state. A client can send a full limit of requests just before a boundary and another full limit just after, producing twice the limit in a span approaching zero. The boundary is the same instant for every client, so the effect is synchronised across your whole traffic rather than isolated.

What is the difference between a sliding log and a sliding window counter?

A sliding log stores a timestamp for every request in the trailing window and counts what survives trimming, which is exact but costs memory proportional to the limit. A sliding window counter keeps a count for the current bucket and the previous one and weights the previous by how much of it still overlaps the window, which is approximate but uses two integers regardless of the limit.

Why do I need a Durable Object rather than a counter at each edge location?

Because independent counters at N locations enforce N times your limit between them, which is the same failure a sliding window was meant to fix. A Durable Object is a single globally unique instance, single-threaded and transactional, so increments for one key cannot race. That guarantee is what you are paying the network hop for.

How much latency does a Durable Object call add?

Between about five milliseconds when the object lives in the same region and two hundred milliseconds or more when it does not, because the object stays where it was first created. Enable smart placement to shorten the common case, always set an explicit timeout below your request budget, and keep the object off the hot path for traffic that is nowhere near its limit.

When should I shard a rate limit key?

When one key is hot enough that requests queue behind each other inside a single-threaded object, which in practice means a shared global or per-tenant key on a high-volume endpoint. Do not shard per-IP keys: the key space is already wide, so sharding multiplies your object count without reducing any single object’s load. Sharding also makes the aggregate limit approximate rather than exact.

Should the limiter fail open or fail closed when the object is unreachable?

It depends on the route, and a single global policy is the only clearly wrong answer. Ordinary read traffic should fail open, because a counter outage must not become a site outage. Authentication, password reset and payment endpoints should fail closed, because an unmetered login endpoint during an outage is exactly when credential stuffing succeeds.