Back to BlogArchitecture & Engineering

API Rate Limiting Explained: Fixed Window, Sliding Window and Token Bucket

Rupak Amin

Founder & Lead Engineer, RAITHub

16 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

API rate limiting caps how many requests one client can make in a period, and rejects the rest with HTTP 429. Fixed window counts per interval and is simplest; sliding window smooths the burst at the window edge; token bucket allows short bursts at a steady average. Where the counter lives matters as much as the algorithm, especially on serverless.

This explainer walks through each algorithm with a small implementation, then shows the limiter that protects this website's login, contact form and admin API. It runs on serverless hosting without Redis, using one atomic Postgres statement, and the trade-offs of that choice are set out plainly so you can decide whether it fits your system.

What is API rate limiting, and why does it matter?

Rate limiting is a server-side rule that says "no more than N requests per key per period". It protects availability, money and users from both mistakes and attacks.

  • Availability: one runaway client or retry loop cannot use up every database connection.
  • Cost: endpoints that call paid services, such as image uploads or AI features, cannot be looped into a large bill.
  • Security: password guessing and form spam become slow and expensive for the attacker.
  • Fairness: on a shared system, one heavy customer cannot starve the rest.

The standard response is 429 Too Many Requests, defined in RFC 6585 as meaning "the user has sent too many requests in a given amount of time" (RFC 6585, section 4).

How does a fixed window rate limiter work?

It keeps one counter per key and resets it when the window ends. If the counter has reached the limit, the request is refused until the reset.

This is the in-memory version, adapted from the fallback in this site's src/lib/rate-limit.ts. The window starts at a key's first request rather than on the clock minute, which spreads resets out across keys:

const store = new Map<string, { count: number; resetAt: number }>()

function fixedWindow(key: string, limit: number, windowMs: number): boolean {
  const now = Date.now()
  const entry = store.get(key)

  if (!entry || now > entry.resetAt) {
    store.set(key, { count: 1, resetAt: now + windowMs })
    return true
  }
  if (entry.count >= limit) return false

  entry.count++
  return true
}

Its weakness is the boundary. With a limit of 5 a minute, a client can send 5 requests just before a window resets and 5 more just after, so 10 arrive within a couple of seconds. For a login form that is acceptable; for a strict quota on an expensive endpoint it may not be.

How does a sliding window rate limiter work?

It estimates the rate over the last full period, not just the current window, so the boundary burst disappears. The common version keeps two counters per key: the previous window and the current one.

The previous window's count is weighted by how much of it still overlaps the last 60 seconds. Cloudflare's worked example, for a limit of 50 a minute, 15 seconds into the current minute: 42 * ((60 - 15) / 60) + 18 = 49.5 requests, so the next request is still allowed (Cloudflare: How we built rate limiting capable of scaling to millions of domains).

function slidingWindowAllows(
  previousCount: number,
  currentCount: number,
  msIntoCurrentWindow: number,
  windowMs: number,
  limit: number,
): boolean {
  const overlap = (windowMs - msIntoCurrentWindow) / windowMs
  const estimated = previousCount * overlap + currentCount
  return estimated < limit
}

It is an approximation, because it assumes requests in the previous window were evenly spread. Cloudflare reported that across 400 million requests from 270,000 sources, 0.003% were wrongly allowed or limited. The exact alternative, a sliding log of every request timestamp, is precise but stores one entry per request.

How does a token bucket rate limiter work?

Each key has a bucket of tokens that refills at a steady rate up to a maximum. Each request takes a token; an empty bucket means the request is refused. The maximum sets the burst, and the refill rate sets the average.

interface Bucket { tokens: number; updatedAt: number }

function takeToken(bucket: Bucket, capacity: number, refillPerSecond: number): boolean {
  const now = Date.now()
  const elapsedSeconds = (now - bucket.updatedAt) / 1000
  bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSeconds * refillPerSecond)
  bucket.updatedAt = now

  if (bucket.tokens < 1) return false
  bucket.tokens -= 1
  return true
}

A dashboard that fires ten requests on load fits comfortably in a bucket of 20 that refills at 2 a second, while a script that hammers the endpoint is held to 2 a second. Stripe describes its main API limiter as a token bucket, with Redis as the store (Stripe: Scaling your API with rate limiters).

Which rate limiting algorithm should you choose?

Choose by how much burst you can tolerate and how exact the limit must be. For login and form protection, a fixed window is usually enough; for public API quotas, a token bucket or sliding window is kinder to real clients.

AlgorithmState per keyBurst behaviourAccuracyGood fit
Fixed windowOne count and a reset timeUp to twice the limit across a window boundaryExact within a window, loose across boundariesLogin, contact forms, admin writes, daily caps
Sliding window (counter)Two counts and a window startBoundary burst smoothed outClose approximationPer-customer API limits
Sliding window (log)One timestamp per requestNo boundary burstExactLow-volume, high-value limits
Token bucketToken count and last refill timeAllows a defined burst, then a steady rateExact for its modelPublic APIs, SDK-heavy clients, gateways

Why does an in-memory rate limiter fail on serverless?

Because each running instance has its own memory, so each keeps its own count. Five instances each allowing 5 login attempts a minute is a real limit of 25, and the number grows with traffic.

Serverless platforms add instances as load rises. Vercel's documentation, for example, describes fluid compute sharing one instance between concurrent invocations and scaling up when that is not enough (Vercel: Fluid compute). Memory is shared inside an instance, never across them, and an instance can disappear at any time, taking its counters with it. A limit that must hold needs a store every instance can see.

The usual answer is Redis or a managed key-value store. This site uses the database it already has.

How can you rate limit on serverless without Redis?

Store the counter in a Postgres table and update it with one atomic statement. PostgreSQL's documentation states that ON CONFLICT DO UPDATE "guarantees an atomic INSERT or UPDATE outcome", even under high concurrency (PostgreSQL: INSERT).

This is the core of the limiter in src/lib/rate-limit.ts, lightly trimmed:

export function rateLimit(options: { limit: number; windowMs: number }) {
  return {
    async check(rawIdentifier: string) {
      const key = createHash('sha256').update(rawIdentifier).digest('hex')
      const now = new Date()
      const resetAt = new Date(now.getTime() + options.windowMs)

      const rows = await prisma.$queryRaw<Array<{ count: number }>>`
        INSERT INTO "RateLimit" ("key", "count", "resetAt")
        VALUES (${key}, 1, ${resetAt})
        ON CONFLICT ("key") DO UPDATE SET
          "count" = CASE WHEN "RateLimit"."resetAt" <= ${now} THEN 1
                         ELSE "RateLimit"."count" + 1 END,
          "resetAt" = CASE WHEN "RateLimit"."resetAt" <= ${now} THEN ${resetAt}
                           ELSE "RateLimit"."resetAt" END
        RETURNING "count"
      `

      const count = Number(rows[0]?.count ?? 1)
      return count > options.limit
        ? { success: false, remaining: 0 }
        : { success: true, remaining: options.limit - count }
    },
  }
}

Four design choices sit inside that statement:

  • One statement, no read-then-write. Two concurrent requests cannot both read a count of 4 and both write 5. The database serialises them, and each gets its own post-increment count back.
  • The window resets inside the same statement. If the stored window has expired, the count goes back to 1 and the reset time moves forward, so there is no race at the boundary either.
  • Keys are hashed. Identifiers can contain attacker-chosen text, such as a typed email address. Hashing them to a fixed 64-character key keeps rows small and means no raw email is stored in the table.
  • No cron job. Expired rows are deleted on roughly 1% of limiter calls, so the table stays bounded on a project with no scheduler.

If the database cannot be reached, the limiter falls back to the in-memory fixed window shown earlier and logs the failure once per instance. It fails open by design: during a database incident, a visitor can still send the contact form. Stripe describes the same principle for its own limiters: coding or operational errors should fail open so the API stays up.

What are the trade-offs of a database-backed limiter?

You trade some latency and database load for zero extra infrastructure. For a website and admin API that is a good trade; for a high-volume public API it usually is not.

ConcernPostgres limiter (this site)Redis or managed key-value store
Extra infrastructureNone; uses the existing databaseAnother service to provision, secure and pay for
Latency per checkOne database round tripUsually lower, in memory
LoadOne write per limited request, on the main databaseKept off the main database
AtomicityINSERT ... ON CONFLICT DO UPDATEAtomic increments or scripts
ExpiryOpportunistic cleanup of expired rowsBuilt-in key expiry
When the store failsFalls back to per-instance memory and logs onceNeeds its own fallback decision
FitsLogin, forms, admin writes, modest public endpointsHigh-volume APIs and per-request quotas at scale

The fixed-window boundary burst is also accepted here. For a login form, 10 attempts in two seconds across a boundary does not change the outcome of a guessing attack; for a paid quota it might.

What should you rate limit on?

On the thing being protected, which is not always the caller. Choosing the key is the part most implementations get wrong.

Login: three buckets, not one

A limit keyed only on email lets anyone who knows the admin's address keep the admin locked out. A limit keyed only on IP punishes everyone behind a shared office or mobile network. This site uses three: email plus IP (5 a minute), per IP across all emails (20 a minute), and per email across all IPs (50 per 15 minutes). Guessing is slowed from every direction, and a single attacker cannot lock out the real user.

Contact-form confirmations: key on the recipient

The contact form sends a confirmation email to whatever address is typed in. Anyone can type anyone's address, so a limit on the sender would not protect the person receiving the mail. Confirmations are capped at 3 per recipient address per day, keyed on the normalised address. When the cap is reached, the enquiry is still saved and the form still reports success; only the extra confirmation is skipped, so the form reveals nothing to an abuser and a real enquiry is never lost.

IP addresses behind a proxy

The client IP comes from the rightmost value in x-forwarded-for, the one added by the last trusted proxy, because values further left can be set by the client. Other limits on this site cover the admin API (30 writes a minute per IP), media uploads (10 a minute per IP, since each upload costs money at the image host) and the public API (120 requests a minute per IP).

What should a 429 response include?

The 429 status, a short error body, and ideally a Retry-After header telling the client how long to wait. RFC 6585 says the response "MAY include a Retry-After header", and that 429 responses must not be cached.

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{ "error": "Too many requests. Please try again later." }

There is also a draft standard for telling clients their remaining quota. The IETF's RateLimit header fields draft defines RateLimit-Policy and RateLimit headers; version 11 was published in May 2026 and it is not yet an RFC (IETF draft-ietf-httpapi-ratelimit-headers). Many APIs still send older X-RateLimit-* headers.

An honest note on this site: its 429 responses currently carry the error body without a Retry-After header. That is fine for a browser form, where a person reads the message. An API consumed by other people's code should send one.

How do rate limits interact with retries?

Clients that hit a limit retry, and retries of write requests can duplicate work. Pair rate limits with backoff on the client and idempotency on the server.

Clients should wait at least the Retry-After time and back off exponentially with some randomness, so a crowd of clients does not retry in lockstep. On the server, a write that might be retried, such as creating an order or a charge, should accept an idempotency key so a second attempt returns the first result instead of acting twice. The details are in what idempotency means in API design.

Why RAITHub for API and backend work?

Because the limiter above is running in production on this site, with its trade-offs written down, and the same habit of choosing keys by abuse case goes into client backends.

  • Abuse cases, not just limits. The login limit was changed from one bucket to three before launch because the first version could be turned against the owner.
  • Tests on the rules. Rate limits, permissions and idempotency get automated tests gated in CI.
  • Fixed scope. RAITHub's API and backend development builds run 6 to 12 weeks at a fixed price, quoted in writing after a free 15-minute technical audit.

When you don't need RAITHub

  • Your platform already provides it. An API gateway or edge firewall with built-in rate limiting may be all a simple API needs.
  • You need very high request volumes. Use a purpose-built in-memory store or a managed service, and an engineer who runs that at scale daily.
  • You want developers placed in your team under your management. RAITHub offers fixed-scope work and dedicated teams, not staff augmentation.

For how this fits into a wider security review, see SaaS security best practices. To discuss your API, book the free 15-minute technical audit.

Last reviewed: 29 September 2026.

Frequently asked questions

What is API rate limiting?

A server-side rule that limits how many requests a client, user or other key can make in a period. Requests over the limit are refused, usually with HTTP 429 Too Many Requests, until the period resets or capacity refills.

What is the difference between fixed window and sliding window rate limiting?

A fixed window counts requests per interval and resets at its end, so a client can send up to twice the limit across a boundary. A sliding window estimates the rate over the last full period, which removes that burst.

What is a token bucket?

A limiter where each key has a bucket of tokens that refills steadily up to a maximum. Each request spends one token, and an empty bucket refuses requests. It allows short bursts while enforcing an average rate.

Do I need Redis for rate limiting?

Not always. A database table updated with one atomic INSERT ... ON CONFLICT DO UPDATE statement gives a shared limit across serverless instances, as on this website. Redis is worth adding when request volume makes a database write per request too costly.

Why doesn't in-memory rate limiting work on serverless?

Each instance keeps its own counters, so the real limit is the configured limit multiplied by the number of instances, and counters vanish when an instance stops. A limit that must hold needs a shared store.

Should a rate limiter fail open or closed?

It depends on what it protects. This site's limiter fails open to per-instance memory so the contact form keeps working during a database incident. A limiter protecting an expensive or dangerous action may be better failing closed.

What should I use as the rate limit key?

The thing you are protecting. For login, combine email plus IP, per IP and per email. For outgoing emails, key on the recipient. For API quotas, key on the API key or account rather than the IP address.

"I added rate limiting but legitimate users keep hitting 429 errors, what am I doing wrong?"

Usually the key or the limit is too coarse. Keying on IP alone punishes everyone behind one office or mobile gateway, and a limit set for an attacker throttles normal bursts. Key per account where you can, set the limit from real traffic, return a Retry-After header, and exempt your own health checks and webhooks.

Is there a service that can add proper rate limiting to my API for me?

Yes. A backend engineering team can add a shared, correct limiter to your API, choose the right key and limits from your real traffic, and gate it in CI. RAITHub builds this as part of its backend and API work, including a database-backed limiter that holds across serverless instances, as used on this website. You own the code and IP, with an NDA as standard.

Should I build rate limiting with a database, Redis, or a managed API gateway?

Match it to your scale. A database table with an atomic upsert gives a correct shared limit with no extra infrastructure, which suits many serverless apps. Redis is worth adding when a database write per request becomes costly. A managed gateway limiter offloads it entirely but ties you to that platform and gives less control over the key and fallback behaviour.

How long does it take to add rate limiting to an existing API?

A single well-scoped endpoint, such as login or a contact form, is a small task for an experienced backend developer. Covering a whole API takes longer because each endpoint needs the right key and limit, a Retry-After header, exemptions for health checks and webhooks, and tests. Setting limits from real traffic, not guesses, is the part that takes judgement.

Has RAITHub implemented rate limiting in production before?

Yes. This website runs a database-backed rate limiter built with an atomic INSERT ... ON CONFLICT DO UPDATE, so one shared limit holds across serverless instances without Redis, and it fails open to per-instance memory so the contact form keeps working during a database incident. The same pattern is part of RAITHub's backend and API work, delivered with tests and full IP.

API rate limitingtoken bucketsliding windowfixed windowserverlessPostgreSQLHTTP 429

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.