API Rate Limiting: Strategies and Implementation

API rate limiting caps how many requests a client may make in a time window, and refuses the rest with 429 Too Many Requests and a Retry-After header. The main strategies are fixed windows, sliding windows and token buckets; the real decisions are what you count per (user, IP address, API key or the whole project) and which endpoints get their own limit. Implement it before requests reach your database: at a gateway, in middleware, or with your platform's built-in rules.

#Why rate limit at all

Four different problems, one tool:

  • Abuse. Password guessing, scraping, comment spam, fake sign-ups.
  • Cost. An expensive function (AI calls, PDF exports, e-mail) called in a loop by a bug or a bot.
  • Fairness. One heavy customer should not slow down everyone else.
  • Protection. A burst should hit a cheap counter, not your database.

Each one suggests a different key and window. Spam is per user per minute. Password guessing is per IP and per account. Cost is per user or per organization per hour or day. Fairness is per customer.

#Rate limiting algorithms

#Fixed window

Count requests per key in a window that starts at a fixed time (each minute, each hour). Reset at the boundary.

JavaScript
// In-memory fixed window: fine for one process, not for many
const counts = new Map()

function allowFixed(key, limit, windowMs) {
  const window = Math.floor(Date.now() / windowMs)
  const k = `${key}:${window}`
  const n = (counts.get(k) ?? 0) + 1
  counts.set(k, n)
  return n <= limit
}

Simple and cheap. The flaw: a client can send limit requests at 12:00:59 and another limit at 12:01:00, double the limit in two seconds.

#Sliding window

Count requests in the last N seconds, measured from now. A common approximation weights the previous window by how much of it still overlaps:

JavaScript
function allowSliding(key, limit, windowMs) {
  const now = Date.now()
  const current = Math.floor(now / windowMs)
  const elapsed = (now % windowMs) / windowMs
  const prev = counts.get(`${key}:${current - 1}`) ?? 0
  const curr = counts.get(`${key}:${current}`) ?? 0
  if (prev * (1 - elapsed) + curr >= limit) return false
  counts.set(`${key}:${current}`, curr + 1)
  return true
}

No double-burst at the boundary, still only two counters per key. This is what most production limiters use.

#Token bucket

Each key has a bucket of tokens that refills at a steady rate up to a maximum. Each request takes one token. An empty bucket means refuse.

JavaScript
const buckets = new Map()

function allowBucket(key, capacity, refillPerSec) {
  const now = Date.now() / 1000
  const b = buckets.get(key) ?? { tokens: capacity, at: now }
  b.tokens = Math.min(capacity, b.tokens + (now - b.at) * refillPerSec)
  b.at = now
  if (b.tokens < 1) { buckets.set(key, b); return false }
  b.tokens -= 1
  buckets.set(key, b)
  return true
}

Token buckets allow short bursts (up to capacity) while holding a long-term average. Good for APIs where clients legitimately send a few requests at once, such as a page loading five resources.

#Leaky bucket

Requests enter a queue that drains at a fixed rate; when the queue is full, new ones are refused. It smooths traffic rather than refusing it, which makes it a throttling tool more than a limit.

#Which to choose

AlgorithmBurstsMemory per keyUse for
Fixed windowUp to 2x at boundaries1 counterCoarse daily or hourly quotas
Sliding windowNo2 countersMost per-user and per-IP limits
Token bucketYes, up to capacity2 numbersAPIs with natural bursts
Leaky bucketQueuedA queueSmoothing calls to a fragile upstream

#API throttling vs rate limiting

The terms are used loosely, but there is a useful distinction:

  • Rate limiting refuses. Over the limit, you get 429 right away. The client decides what to do.
  • Throttling slows down. Over the limit, requests are queued or delayed and served later, at a controlled pace.

Rate limiting protects your server and is easy to reason about. Throttling is friendlier for batch clients but holds connections open and hides problems. For public APIs, refuse with 429 and let clients back off. Use throttling inside your own system, for example when your workers call a third-party API that allows only 10 requests a second.

#Choosing the key

KeyGood forWatch out for
Per user (token sub)Spam, cost, fairnessUnauthenticated endpoints have no user
Per IP addressSign-in, sign-up, public endpointsOffices and mobile carriers share IPs
Per API key or customerB2B APIs, paid tiersNeeds a key on every request
Per account targetedPassword guessing on one accountCan be used to lock out a real user
Whole projectProtecting an upstream with a hard capOne client can use the whole budget

The usual combination: per user where there is a session, per IP where there is not, and per project for anything that calls a paid upstream.

#The response contract

Clients can only behave well if you tell them what happened:

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

{"code":"rate_limited","message":"Try again in 42 s.","retry_after":42}
  • 429 is the status code for this (RFC 6585).
  • Retry-After says how many seconds to wait.
  • A machine-readable body lets SDKs show a useful message.
  • Do not count refused requests, or a client that keeps retrying never gets through.

#The client side: back off

JavaScript
async function withRetry(fn, attempts = 4) {
  for (let i = 0; i < attempts; i++) {
    const res = await fn()
    if (res.status !== 429) return res
    const wait = Number(res.headers.get('Retry-After') ?? 2 ** i)
    await new Promise((r) => setTimeout(r, wait * 1000 + Math.random() * 250))
  }
  throw new Error('Rate limited')
}

Honor Retry-After when it is present, use exponential backoff when it is not, and add a little random jitter so many clients do not retry at the same instant.

#How to implement rate limiting in an API on StitchDB

If your API is a StitchDB project, you do not need a gateway or Redis. There are three layers.

#1. Plan limits that always apply

Each project has an API request rate per second covering REST, Auth and Storage: 50 on Free, 1,000 on Pro, 2,000 on Scale and 5,000 on Enterprise (from the live plans API). Requests above it get 429 (API limits). Auth has its own per-IP limits for sign-ins, sign-ups, token refreshes and codes (auth limits).

#2. Rules in Studio

In Studio open API › Rate limits and add a rule. A rule picks the service (Database REST API, Auth, Storage or Edge Functions), a path, methods, a limit and how to count (rate limits docs). Examples from the docs:

RuleApplies toPathMethodsLimit
CommentsDatabase (REST API)commentsPOST10 per minute, per user
Sign-insAuthtokenPOST5 per minute, per IP
Sign-upsAuthsignupPOST3 per hour, per IP
Avatar uploadsStorageobject/avatars/*POST, PUT20 per hour, per user
Expensive functionEdge Functionsgenerate-reportAny5 per minute, per user

How the rules behave:

  • Counting is a sliding window from one second to one day, so there is no double burst at the turn of a minute.
  • Per user counts the sub of the access token; calls without a session fall back to per IP.
  • Refused requests do not count, so a client that waits for Retry-After gets through.
  • Requests with the service_role key are never limited, so your own servers and jobs are unaffected.
  • Refused requests get 429 with Retry-After, X-RateLimit-Limit and a JSON body, before they reach your database.

Start a new rule in Only record it mode: requests go through, and the ones that would have been refused show up under Latest refused requests. Switch to refusing once the numbers look right. Rule counts per project are 3 on Free, 25 on Pro, 100 on Scale and 500 on Enterprise.

#3. Limits that depend on your data, in SQL

"5 exports an hour per organization" needs your data to pick the key. Install the SQL helper (API › Rate limits › In SQL) and call ratelimit.enforce from a trigger:

SQL
create function public.exports_rate_limit() returns trigger language plpgsql as $$
begin
  perform ratelimit.enforce('exports', 5, interval '1 hour', new.org_id::text);
  return new;
end $$;

create trigger exports_rate_limit before insert on public.exports
  for each row execute function public.exports_rate_limit();

Over the limit, the insert is rolled back and the REST API returns 429 with Retry-After. Without the fourth argument, the key is the signed-in user (or the IP for anonymous callers). It works in writes only; read-only requests cannot record a call.

On the client, the SDK returns this as an ordinary error:

JavaScript
const { error } = await stitchdb.from('comments').insert({ body })
if (error?.status === 429) showToast('Slow down a little and try again.')

#Testing your limits

  1. Start in record-only mode and watch real traffic for a few days.
  2. Set limits well above normal use. A limit that real users hit is a bug.
  3. Load-test the 429 path: make sure your clients back off instead of retrying in a loop.
  4. Turn on alerts for rules that trigger a lot. StitchDB can e-mail you when a rule refuses more than a set number of requests in 5 minutes.

#FAQ

#What is a good default rate limit for an API?

There is no universal number. Measure normal use per user and set the limit a few times higher. For sign-in, a handful of attempts per minute per IP is typical.

#Should I rate limit per user or per IP?

Per user when there is a session, because many users can share one IP. Per IP for unauthenticated endpoints like sign-in and sign-up.

#What is the difference between API throttling and rate limiting?

Rate limiting refuses excess requests with 429. Throttling delays them and serves them later. Public APIs usually refuse; internal workers often throttle.

#Do rate limits replace authentication?

No. They limit how often; authentication and authorization decide who and what. Use both.

Add your first rule in Studio: read rate limits and create a free project. For auth-specific limits, see authentication best practices.