DynamoDB rate limiting is one of those patterns people only build once it already hurts: a script hammers the booking API a hundred times per second, the Lambda functions dutifully scale out, and at the end of the month someone wonders about the bill. Or worse, a single bot grabs half the seats for opening night, and real guests go home empty-handed.

The topic keeps coming back to me because the obvious answer (“API Gateway does that for us”) is only half true. So in this article I build a per-account limit that actually holds: a token bucket in DynamoDB, written in TypeScript and deployed with the Serverless Framework. The example is once again the fictional ticket shop of the small cabaret venue “Little Stage”, which you may already know from the article on seat holds with DynamoDB TTL.

In short

API Gateway throttling works per route or per account and only on a best-effort basis, not per customer. A token bucket in DynamoDB stores the token count and a timestamp for each customer and writes both with a conditional write, so concurrent Lambda invocations cannot overwrite each other. The biggest pitfall is a counter that lives only in the memory of a single Lambda instance.

What you will learn in this article

  • Where API Gateway throttling ends and your own rate limiting begins
  • How a token bucket works and when a fixed window is enough
  • How to protect concurrent writes with optimistic locking in DynamoDB
  • How to return a clean 429 with Retry-After
  • A tested project with the Serverless Framework and TypeScript

Why DynamoDB rate limiting instead of just API Gateway?

Rate limiting means that a service accepts only a certain number of requests per time unit from one sender and rejects the rest, usually with HTTP status 429 “Too Many Requests”. That protects against abuse, shares scarce resources fairly and caps the cost of services that charge per call.

API Gateway already comes with throttling. For HTTP APIs, however, there are only two levels: one limit for all APIs of an AWS account per Region, and limits per stage or route. A per-customer limit is missing. So when a single client maxes out the route POST /reservations, the 429 hits every other customer as well. On top of that, AWS states clearly that these limits are best-effort targets, with no hard ceiling behind them.

REST APIs additionally offer usage plans with API keys, in other words quotas per key. According to the docs, those are best effort too, and API keys are not meant for authentication. For a fair limit per signed-in account you therefore need a counter that you control yourself. By the way, I showed how to validate partner keys properly in the article about the Lambda authorizer for HTTP APIs.

Why a counter in Lambda memory is not enough

The first idea is often a map in the function’s global scope. It works beautifully on your laptop and not at all in production. Under load, Lambda starts many instances in parallel, and each one has its own memory. Ten instances with a limit of ten requests each suddenly add up to a hundred. Instances can also disappear at any time and restart with an empty counter.

The state therefore has to live somewhere all instances can see. DynamoDB is a good fit: it scales without servers, answers within a few milliseconds and can tie writes to conditions. Those conditions are the heart of the pattern.

The token bucket in plain words

Picture a bucket of coins for each account; the coins are the tokens. Every request takes one coin out. When the bucket is empty, the request is rejected. Meanwhile, new coins drip in at a steady pace, but never more than the bucket holds. Incidentally, API Gateway uses exactly this algorithm for its own throttling.

Diagram: a bucket with capacity 10 refills at 0.5 tokens per second; a request takes one token or gets a 429

Capacity and refill rate fully describe the bucket.

Two numbers describe the bucket completely. The capacity defines how many requests someone may send at once, that is, the burst. The refill rate sets the sustained pace. In the example, the bucket holds 10 tokens and refills at 0.5 tokens per second. A customer can therefore click ten seats in quick succession, but after that only trigger one reservation every two seconds, or 30 per minute on average.

Here is the trick for DynamoDB: nothing has to be refilled in the background. It is enough to store the token count and the time of the last calculation per account. On the next request we work out how many tokens would have flowed in since then. No timer, no cron job, no cost for quiet accounts.

Token bucket or fixed window?

The simplest alternative is a fixed window: one counter per account and minute that goes up with every request. In DynamoDB this takes a single write, but it has a well-known weakness at the window boundary. Anyone who sends 30 requests in the last second of one minute and 30 in the first second of the next ends up with 60 in two seconds.

Criterion Fixed window Token bucket
DynamoDB calls per allowed request 1 write 1 read + 1 write
Behaviour at the window boundary briefly up to twice the limit smooth
Bursts uncontrolled within the window set deliberately via the capacity
Calculating Retry-After until the window ends until the next token, usually shorter
Good fit for rough quotas, e.g. 1,000 per day fair limits per second or minute

For the ticket shop I pick the token bucket, because it lets people with quick clicks through and still reliably slows down scripts. I will still show the fixed window further down, since it is often the better choice for daily quotas.

How a request flows through the limiter

Before we get to the code, it helps to follow a single request. The Lambda function reads the bucket with a strongly consistent read, does the math and writes the new state only if nobody else has touched it in the meantime.

🎬 How a request flows through the rate limiter – this animated diagram only runs on my site: watch it on sascha-ms.de

This technique is called optimistic locking: we lock nothing up front, we remember a version number instead and check on write whether it still matches. If a concurrent invocation got there first, the condition fails and we simply read again. With per-account limits this rarely happens, because a single account seldom sends many requests in exactly the same millisecond.

Step by step: DynamoDB rate limiting with the Serverless Framework

Prerequisites

  • Node.js 22 and npm
  • an AWS account with credentials in your shell (for example via aws configure sso)
  • Serverless Framework v4 (npm i -g serverless); on first start, v4 asks you to sign in
  • basic knowledge of Lambda, HTTP APIs and DynamoDB

Step 1: Create the project

Create an empty folder and install the dependencies. For the tests I use the built-in Node.js test runner together with tsx, so the TypeScript files run without a build step.

mkdir little-stage-rate-limit && cd little-stage-rate-limit mkdir src test # create package.json and tsconfig.json as shown below, then: npm install 
{ "name": "dynamodb-rate-limiting", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc --noEmit", "test": "node --import tsx --test test/*.test.ts" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.1161.0", "@aws-sdk/lib-dynamodb": "^3.1161.0" }, "devDependencies": { "@types/aws-lambda": "^8.10.164", "@types/node": "^22.20.5", "tsx": "^4.23.15", "typescript": "^7.0.2" } } 
{ "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "skipLibCheck": true, "types": ["node"], "allowImportingTsExtensions": true }, "include": ["src", "test"] } 

Step 2: The bucket logic as a pure function

The calculation itself knows nothing about DynamoDB or Lambda. That is on purpose: you can test it with fixed timestamps instead of waiting for the clock. refill tops up the bucket based on elapsed time, and take makes the decision and also returns the wait time for the Retry-After header.

// src/token-bucket.ts // Pure token bucket logic: no AWS calls, which makes it easy to test. export interface BucketConfig { /** Maximum number of tokens in the bucket (allowed burst). */ capacity: number; /** How many tokens flow back in per second. */ refillPerSecond: number; } export interface BucketState { /** Current token count, may have decimals. */ tokens: number; /** Time of the last calculation in milliseconds. */ updatedAt: number; } export interface Decision { allowed: boolean; state: BucketState; /** Whole tokens left after this request. */ remaining: number; /** Seconds until the next free token (0 if allowed). */ retryAfterSeconds: number; } /** Refills the bucket based on elapsed time, up to capacity. */ export function refill( state: BucketState | undefined, config: BucketConfig, now: number, ): BucketState { if (!state) { // Unknown account: the bucket starts full. return { tokens: config.capacity, updatedAt: now }; } const elapsedSeconds = Math.max(0, now - state.updatedAt) / 1000; const tokens = Math.min( config.capacity, state.tokens + elapsedSeconds * config.refillPerSecond, ); return { tokens, updatedAt: now }; } /** Tries to take cost tokens and returns the decision. */ export function take( state: BucketState | undefined, config: BucketConfig, now: number, cost = 1, ): Decision { const current = refill(state, config, now); if (current.tokens >= cost) { const next = { tokens: current.tokens - cost, updatedAt: now }; return { allowed: true, state: next, remaining: Math.floor(next.tokens), retryAfterSeconds: 0, }; } const missing = cost - current.tokens; return { allowed: false, state: current, remaining: 0, retryAfterSeconds: Math.ceil(missing / config.refillPerSecond), }; } /** Seconds until an empty bucket is full again. */ export function secondsUntilFull(config: BucketConfig): number { return Math.ceil(config.capacity / config.refillPerSecond); } 

Note the Math.max(0, …) during refill. The clocks of different Lambda instances can be a few milliseconds apart. Without this guard, a timestamp “from the future” could push the token count below zero.

Step 3: Storing with optimistic locking

Each bucket is an item with the key pk, for example account#customer-42. Besides the token count and the timestamp we store a version number. The write rule is simple: if the item does not exist yet, it may only be created; if it exists, the version must be unchanged.

// src/bucket-store.ts // Stores one bucket per key in DynamoDB, with optimistic locking via "version". import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, PutCommand, } from "@aws-sdk/lib-dynamodb"; import type { BucketState } from "./token-bucket.js"; export interface StoredBucket { state?: BucketState; /** 0 means: there is no item yet. */ version: number; } export interface BucketStore { load(key: string): Promise<StoredBucket>; /** Returns false if another invocation was faster. */ save( key: string, state: BucketState, expectedVersion: number, expiresAt: number, ): Promise<boolean>; } export class DynamoBucketStore implements BucketStore { constructor( private readonly tableName: string, private readonly doc = DynamoDBDocumentClient.from(new DynamoDBClient({})), ) {} async load(key: string): Promise<StoredBucket> { const result = await this.doc.send( new GetCommand({ TableName: this.tableName, Key: { pk: key }, // Strongly consistent read, otherwise we might calculate with stale data. ConsistentRead: true, }), ); if (!result.Item) return { version: 0 }; return { state: { tokens: Number(result.Item.tokens), updatedAt: Number(result.Item.updatedAt), }, version: Number(result.Item.version), }; } async save( key: string, state: BucketState, expectedVersion: number, expiresAt: number, ): Promise<boolean> { try { await this.doc.send( new PutCommand({ TableName: this.tableName, Item: { pk: key, tokens: state.tokens, updatedAt: state.updatedAt, version: expectedVersion + 1, expiresAt, }, ConditionExpression: expectedVersion === 0 ? "attribute_not_exists(pk)" : "version = :expected", ExpressionAttributeValues: expectedVersion === 0 ? undefined : { ":expected": expectedVersion }, }), ); return true; } catch (error) { if ((error as Error).name === "ConditionalCheckFailedException") { return false; } throw error; } } } 

Two details matter. First, ConsistentRead: true returns the current state instead of a possibly stale copy. It costs twice the read capacity, but it prevents us from calculating with an old token count. Second, save turns only the ConditionalCheckFailedException into false. Every other error, such as throttling by DynamoDB, keeps propagating.

Step 4: The limiter with retries

The limiter ties both together: read, calculate, write conditionally and start over on a conflict. Rejected requests write nothing at all. That is convenient and saves money as well, because during an attack most requests are rejections.

// src/rate-limiter.ts // Connects the bucket logic with the store and retries on conflicts. import type { BucketStore } from "./bucket-store.js"; import { secondsUntilFull, take, type BucketConfig, type Decision, } from "./token-bucket.js"; export interface LimiterOptions { config: BucketConfig; store: BucketStore; now?: () => number; /** How often we retry on concurrent access. */ maxAttempts?: number; } export async function consume( key: string, options: LimiterOptions, cost = 1, ): Promise<Decision> { const now = options.now ?? Date.now; const maxAttempts = options.maxAttempts ?? 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const stored = await options.store.load(key); const time = now(); const decision = take(stored.state, options.config, time, cost); // Rejected requests write nothing: this saves write capacity. if (!decision.allowed) return decision; // The item expires once the account has been quiet for a while. It then starts full again. const expiresAt = Math.ceil(time / 1000) + secondsUntilFull(options.config) + 3600; const saved = await options.store.save( key, decision.state, stored.version, expiresAt, ); if (saved) return decision; } // Too many concurrent requests on the same key: better reject. return { allowed: false, state: { tokens: 0, updatedAt: now() }, remaining: 0, retryAfterSeconds: 1, }; } 

The expiresAt attribute uses DynamoDB TTL for cleanup. An account that has been quiet for an hour after a full refill no longer needs an item: if it is missing, the bucket starts full anyway. It does not matter here that TTL deletes late, because no business decision depends on it.

Step 5: The Lambda handler with 429 and Retry-After

The handler first determines the key. Signed-in customers are limited by the account ID from the JWT, everyone else by IP address. It then asks the limiter and responds with either 201 or 429 plus Retry-After. The headers x-ratelimit-limit and x-ratelimit-remaining are a common convention rather than a standard, but they help clients pace themselves.

// src/handler.ts // HTTP API handler for reservations in the fictional "Little Stage" ticket shop. import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2, } from "aws-lambda"; import { DynamoBucketStore, type BucketStore } from "./bucket-store.js"; import { consume } from "./rate-limiter.js"; import type { BucketConfig } from "./token-bucket.js"; const config: BucketConfig = { capacity: Number(process.env.BUCKET_CAPACITY ?? 10), refillPerSecond: Number(process.env.REFILL_PER_SECOND ?? 0.5), }; let defaultStore: BucketStore | undefined; function store(): BucketStore { defaultStore ??= new DynamoBucketStore(process.env.BUCKET_TABLE ?? ""); return defaultStore; } /** Signed-in customers by account ID, everyone else by IP address. */ export function limitKey( event: Pick<APIGatewayProxyEventV2WithJWTAuthorizer, "requestContext">, ): string { const sub = event.requestContext.authorizer?.jwt?.claims?.sub; if (typeof sub === "string" && sub.length > 0) return `account#${sub}`; return `ip#${event.requestContext.http.sourceIp}`; } export async function handleReservation( event: APIGatewayProxyEventV2WithJWTAuthorizer, bucketStore: BucketStore, ): Promise<APIGatewayProxyResultV2> { const key = limitKey(event); let decision; try { decision = await consume(key, { config, store: bucketStore }); } catch (error) { // DynamoDB unreachable: the shop keeps running (fail open). console.error(JSON.stringify({ msg: "rate limiter failed", key, error: String(error) })); decision = undefined; } const headersSource:https://dev.to/sascha-ms/dynamodb-rate-limiting-a-token-bucket-for-lambda-3mpk

Related posts