How fixed window, sliding window, token bucket and leaky bucket limiters work, what to return when a client is over the limit, and how to enforce one limit across every instance of a TypeScript API.
Rate limiting caps how many requests a client can send in a given time, and most APIs add it after the first incident: a scraper hammering a search endpoint, a retry loop in a customer's integration, or a login form taking a credential stuffing run. The idea is simple to state, a counter per client per time window, and the details that decide whether it works are which algorithm counts, what key it counts by, and where the counter lives.
An in-memory limiter is fine with one server and stops working once there are three, since each instance only sees a third of the traffic. This guide covers the algorithms, the HTTP conventions for rejected requests, and a limiter in TypeScript that keeps its counters in Redis so every instance enforces the same limit.
Each option is covered below, starting with Encore.
Encore is an open source backend toolkit for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Its caching API gives you a Redis cache cluster declared in application code and typed keyspaces on top of it, and an IntKeyspace with increment and a default expiry is a fixed window rate limiter in a few lines:
// search/ratelimit.ts
import { CacheCluster, IntKeyspace, expireIn } from "encore.dev/storage/cache";
const cluster = new CacheCluster("rate-limit", {
evictionPolicy: "noeviction",
});
// One counter per user, reset when it has not been touched for 60 seconds.
export const requestsPerUser = new IntKeyspace<{ userId: string }>(cluster, {
keyPattern: "requests/:userId",
defaultExpiry: expireIn(60 * 1000),
});
Run in your terminal to get started locally.
The endpoint increments the caller's counter before doing any work and rejects the request once it is over the limit. APIError.resourceExhausted maps to HTTP 429:
// search/search.ts
import { api, APIError } from "encore.dev/api";
import { getAuthData } from "~encore/auth";
import { requestsPerUser } from "./ratelimit";
const LIMIT = 100; // requests per minute per user
export const search = api(
{ expose: true, auth: true, method: "GET", path: "/search" },
async ({ q }: { q: string }): Promise<{ results: string[] }> => {
const { userID } = getAuthData()!;
const count = await requestsPerUser.increment({ userId: userID }, 1);
if (count > LIMIT) {
throw APIError.resourceExhausted("rate limit exceeded, try again in a minute");
}
return { results: await runSearch(q) }; // your search call
},
);
To apply the same limit to many endpoints, move the check into a service middleware that targets auth: true endpoints; the Encore examples repository has a middleware app with a rate limiter. encore run backs the keyspace with a local in-memory implementation of Redis, so you can hit the endpoint in a loop and watch the 429 arrive before deploying anything, and in your AWS or GCP account Encore provisions the cache as ElastiCache or Memorystore. For an AI coding agent the limiter is one file of typed code next to the endpoint, which it can add, tune and test locally in the same change as the feature it protects.
A fixed window counter increments a number per client per window, for example per minute, and rejects requests once the number passes the limit. It costs one atomic increment per request, and Redis INCR with an expiry is the classic implementation. Its weakness is the window edge: a client can send the full limit at 00:59 and again at 01:00, so the worst case is twice the limit in a short span.
A sliding window removes that edge effect by counting over a rolling period: the log variant stores a timestamp per request and counts the ones inside the last N seconds, which is exact but grows with traffic. The counter variant keeps the current and previous window counts and weights the previous one by how much of it still overlaps the rolling window, which Cloudflare describes in its write-up on counting things at scale as accurate enough for production with only two numbers per client.
A token bucket holds up to N tokens and refills at a fixed rate; each request spends a token, and an empty bucket means rejection. It allows short bursts up to the bucket size while holding the average rate, which is why API gateways such as Amazon API Gateway describe their throttling in terms of a rate and a burst. A leaky bucket instead queues requests and processes them at a constant rate, which suits smoothing your own outbound calls to a third-party API more than rejecting inbound traffic.
Reject over-limit requests with 429 Too Many Requests and, where you can, a Retry-After header so well-behaved clients back off. Choose the key deliberately: per API key or user ID for authenticated traffic, and per IP only for anonymous endpoints such as login and signup, where many users can share one address behind a NAT. Decide whether to fail open or closed if Redis is unreachable; most APIs fail open for reads and closed for login attempts.
Start with a fixed window per user or API key, which covers quotas and abuse protection for most backends, and move to a sliding window or token bucket only when customers notice the window edges or need documented burst allowances. Keep the counters in a shared store from the start if the API runs more than one instance.
Pick Encore when you want that limiter as typed code in a TypeScript or Go API, with the Redis cache declared next to it, tested locally with encore run and provisioned in your own AWS or GCP account on deploy.
API rate limiting caps how many requests a client can make in a time window, for example 100 requests per minute per API key. Requests over the limit are rejected, usually with HTTP 429 Too Many Requests and a Retry-After header, which protects the backend from overload and abuse.
A fixed window counter is the simplest and is enough for most APIs. Use a sliding window when bursts at window boundaries matter, and a token bucket when you want to allow short bursts while holding a steady average rate.
Declare a CacheCluster and an IntKeyspace with a defaultExpiry from encore.dev/storage/cache, then call increment on a key such as the user ID at the start of the endpoint or in a service middleware. When the returned count is over the limit, throw APIError.resourceExhausted, and Encore runs the same code against Redis locally and in the cloud.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.