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.
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 headers: Record<string, string> = {
"content-type": "application/json",
"x-ratelimit-limit": String(config.capacity),
};
if (decision && !decision.allowed) {
console.warn(JSON.stringify({ msg: "rate limited", key }));
return {
statusCode: 429,
headers: {
...headers,
"retry-after": String(decision.retryAfterSeconds),
"x-ratelimit-remaining": "0",
},
body: JSON.stringify({
message: "Too many requests. Please try again in a moment.",
}),
};
}
if (decision) headers["x-ratelimit-remaining"] = String(decision.remaining);
// The actual reservation would go here, e.g. a write to the seats table.
const reservationId = crypto.randomUUID();
return {
statusCode: 201,
headers,
body: JSON.stringify({ reservationId }),
};
}
export const handler = (event: APIGatewayProxyEventV2WithJWTAuthorizer) =>
handleReservation(event, store());
Look at the catch block: if DynamoDB is unreachable, the handler lets the request through. I explain why I chose this deliberately in the section on “fail open” below.
Step 6: Infrastructure in serverless.yml
The infrastructure fits into one file: a function behind the HTTP API, an on-demand DynamoDB table with TTL and an IAM role that may call exactly GetItem and PutItem on that table. Serverless Framework v4 bundles TypeScript handlers with esbuild automatically.
# serverless.yml
service: little-stage-rate-limit
frameworkVersion: "4"
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: eu-central-1
memorySize: 256
timeout: 10
environment:
BUCKET_TABLE: !Ref RateLimitTable
BUCKET_CAPACITY: "10"
REFILL_PER_SECOND: "0.5"
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
Resource: !GetAtt RateLimitTable.Arn
functions:
createReservation:
handler: src/handler.handler
events:
- httpApi:
path: /reservations
method: post
resources:
Resources:
RateLimitTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
TimeToLiveSpecification:
AttributeName: expiresAt
Enabled: true
Step 7: Test and deploy
Three small test files cover the calculation, the conflict behaviour and the handler. For this, the store is replaced by an in-memory version that can simulate a concurrent write on demand.
// test/token-bucket.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { refill, take } from "../src/token-bucket.ts";
const config = { capacity: 10, refillPerSecond: 0.5 };
test("new bucket starts full", () => {
const decision = take(undefined, config, 1_000);
assert.equal(decision.allowed, true);
assert.equal(decision.remaining, 9);
});
test("empty bucket rejects and reports the wait time", () => {
const decision = take({ tokens: 0, updatedAt: 1_000 }, config, 1_000);
assert.equal(decision.allowed, false);
assert.equal(decision.retryAfterSeconds, 2);
});
test("tokens refill over time", () => {
const state = refill({ tokens: 0, updatedAt: 0 }, config, 4_000);
assert.equal(state.tokens, 2);
});
test("token count is capped at capacity", () => {
const state = refill({ tokens: 9, updatedAt: 0 }, config, 3_600_000);
assert.equal(state.tokens, 10);
});
test("burst of capacity requests, then 429", () => {
let state;
let allowed = 0;
for (let i = 0; i < 12; i++) {
const decision = take(state, config, 5_000);
if (decision.allowed) allowed++;
state = decision.state;
}
assert.equal(allowed, 10);
});
// test/rate-limiter.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import type { BucketStore, StoredBucket } from "../src/bucket-store.ts";
import { consume } from "../src/rate-limiter.ts";
import type { BucketState } from "../src/token-bucket.ts";
class MemoryStore implements BucketStore {
items = new Map<string, { state: BucketState; version: number }>();
conflictsLeft = 0;
saves = 0;
constructor(private readonly fullTokens = 3) {}
async load(key: string): Promise<StoredBucket> {
const item = this.items.get(key);
return item ? { state: item.state, version: item.version } : { version: 0 };
}
async save(key: string, state: BucketState, expectedVersion: number) {
this.saves++;
if (this.conflictsLeft > 0) {
// Simulates a concurrent invocation that wrote just before us.
this.conflictsLeft--;
const current = this.items.get(key);
this.items.set(key, {
state: { tokens: (current?.state.tokens ?? this.fullTokens) - 1, updatedAt: state.updatedAt },
version: expectedVersion + 1,
});
return false;
}
const current = this.items.get(key)?.version ?? 0;
if (current !== expectedVersion) return false;
this.items.set(key, { state, version: expectedVersion + 1 });
return true;
}
}
const config = { capacity: 3, refillPerSecond: 1 };
test("allows capacity requests, then rejects without writing", async () => {
const store = new MemoryStore();
const options = { config, store, now: () => 10_000 };
const results = [];
for (let i = 0; i < 5; i++) results.push((await consume("account#a", options)).allowed);
assert.deepEqual(results, [true, true, true, false, false]);
assert.equal(store.saves, 3);
});
test("retries after a conflict and counts the other request", async () => {
const store = new MemoryStore();
store.conflictsLeft = 1;
const decision = await consume("account#b", { config, store, now: () => 0 });
assert.equal(decision.allowed, true);
// The "concurrent" invocation used one token, we used one.
assert.equal(decision.remaining, 1);
});
test("returns 429 after too many conflicts", async () => {
const store = new MemoryStore();
store.conflictsLeft = 10;
const decision = await consume("account#c", {
config: { capacity: 100, refillPerSecond: 1 },
store,
now: () => 0,
maxAttempts: 3,
});
assert.equal(decision.allowed, false);
assert.equal(decision.retryAfterSeconds, 1);
});
test("keys are independent of each other", async () => {
const store = new MemoryStore();
const options = { config, store, now: () => 0 };
for (let i = 0; i < 3; i++) await consume("account#x", options);
assert.equal((await consume("account#x", options)).allowed, false);
assert.equal((await consume("account#y", options)).allowed, true);
});
// test/handler.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import type { APIGatewayProxyEventV2WithJWTAuthorizer } from "aws-lambda";
import type { BucketStore } from "../src/bucket-store.ts";
import { handleReservation, limitKey } from "../src/handler.ts";
function event(sub?: string): APIGatewayProxyEventV2WithJWTAuthorizer {
return {
requestContext: {
http: { sourceIp: "203.0.113.7" },
authorizer: sub ? { jwt: { claims: { sub }, scopes: [] } } : undefined,
},
} as unknown as APIGatewayProxyEventV2WithJWTAuthorizer;
}
const emptyStore = (): BucketStore => {
const items = new Map();
return {
load: async (key) => items.get(key) ?? { version: 0 },
save: async (key, state, version) => {
items.set(key, { state, version: version + 1 });
return true;
},
};
};
test("key from account ID or IP address", () => {
assert.equal(limitKey(event("customer-42")), "account#customer-42");
assert.equal(limitKey(event()), "ip#203.0.113.7");
});
test("eleventh request in a burst gets 429 with Retry-After", async () => {
const store = emptyStore();
let last;
for (let i = 0; i < 11; i++) last = await handleReservation(event("customer-1"), store);
assert.ok(last && typeof last === "object");
assert.equal(last.statusCode, 429);
assert.equal(last.headers?.["retry-after"], "2");
});
test("fails open on DynamoDB errors and still reserves", async () => {
const broken: BucketStore = {
load: async () => {
throw new Error("ProvisionedThroughputExceededException");
},
save: async () => true,
};
const original = console.error;
console.error = () => {};
const result = await handleReservation(event("customer-2"), broken);
console.error = original;
assert.ok(typeof result === "object");
assert.equal(result.statusCode, 201);
});
npx tsc --noEmit
npm test
Expected result: tsc prints nothing, and the test run ends with # pass 12 and # fail 0. After that you deploy and fire twelve requests in quick succession.
serverless deploy
ENDPOINT=$(serverless info | grep -o 'https://[^ ]*/reservations')
for i in $(seq 1 12); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$ENDPOINT"
done
# inspect the headers of the last response
curl -si -X POST "$ENDPOINT" | grep -iE '^(HTTP|retry-after|x-ratelimit)'
You should see 201 ten times and 429 twice. Without a JWT authorizer, the example falls back to the IP address as key. A few seconds later things work again, because a token flows back in every two seconds. When you are done, serverless remove tears everything down.
The simple alternative: a fixed window with one UpdateItem
For daily quotas such as “at most 1,000 exports per day”, a token bucket is often more than you need. A counter per account and time window will do. The nice part: a single UpdateItem increments the counter and checks the limit in the same atomic operation. No prior read is needed.
// src/fixed-window.ts
// Simpler alternative: fixed window with a single UpdateItem.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export async function allowInWindow(
tableName: string,
key: string,
limit: number,
windowSeconds: number,
now = Date.now(),
): Promise<boolean> {
const window = Math.floor(now / 1000 / windowSeconds);
try {
await doc.send(
new UpdateCommand({
TableName: tableName,
Key: { pk: `${key}#${window}` },
UpdateExpression: "ADD hits :one SET expiresAt = :exp",
ConditionExpression: "attribute_not_exists(hits) OR hits < :limit",
ExpressionAttributeValues: {
":one": 1,
":limit": limit,
":exp": (window + 2) * windowSeconds,
},
}),
);
return true;
} catch (error) {
if ((error as Error).name === "ConditionalCheckFailedException") return false;
throw error;
}
}
The condition attribute_not_exists(hits) OR hits < :limit is evaluated before the increment. Once the limit is reached, the write fails and the counter stays put. Each window gets its own key, so counting restarts automatically, and TTL removes old windows.
Cost, hot keys and security of DynamoDB rate limiting
What does a request cost?
The item is tiny, well below one kilobyte. An allowed request therefore costs one strongly consistent read (one read unit) and one write (one write unit). A rejected request costs only the read. Worth knowing: according to the docs, a write whose condition fails still consumes write capacity. Many conflicts thus make the pattern more expensive, not only slower.
If you want to weigh whether DynamoDB is the right store for your case at all, my AWS service finder helps you sort it out.
Hot keys: one bucket for everyone is a bad idea
DynamoDB spreads data across partitions, and according to AWS a single partition handles up to 1,000 write units per second. As long as every account has its own bucket, that is no concern. A global limit such as “at most 5,000 reservations per second for everyone” in one single item, on the other hand, becomes a bottleneck and adds lots of conflicts. For global limits like that, API Gateway throttling or reserved concurrency on the function are the better tools.
Fail open or fail closed?
What should happen when the limiter itself fails? “Fail closed” would reject every request, while “fail open” lets them pass. For the ticket shop I choose fail open, because the limiter protects against abuse but is not access control. A short DynamoDB hiccup should not stop all sales. Things look different when the limit caps expensive third-party calls, for example calls to a language model: then fail closed is the more cautious choice. What matters is that you make the decision consciously and log and alert on the failure case.
Which key is the right one?
A limit is only as good as its key. The account ID from a verified token is the most reliable option. IP addresses are a stopgap: behind a corporate NAT many people share one address, and attackers simply switch addresses. Use a more generous limit for anonymous access, and never trust headers like X-Forwarded-For that the client can set itself. That is why the handler uses sourceIp from the API Gateway request context.
Common mistakes and how to avoid them
The most common mistake is the “read, calculate, just write” pattern without a condition. Two concurrent requests then both read five tokens, each subtracts one, and both write four. One request slipped through for free, and under load that quickly becomes many. The version condition prevents exactly this.
Milliseconds in the TTL attribute are just as treacherous. DynamoDB expects seconds; a value in milliseconds lies so far in the future that nothing ever gets deleted. In the code I therefore divide by 1,000 explicitly. Many people also forget that clients need to react sensibly to a 429. A client that fires again immediately makes the problem worse. What good retries look like is covered in the article on exponential backoff with jitter.
Finally, a mistake at the architecture level: rate limiting does not replace protection against large attacks. Floods with millions of requests call for AWS WAF with rate-based rules or CloudFront in front of the API. The DynamoDB bucket provides fairness between customers, not defence at the network level.
My take
I consider custom rate limiting in DynamoDB one of the underrated Serverless patterns. It is little code, costs almost nothing under normal traffic and makes visible a behaviour that otherwise disappears somewhere between account limit and invoice. At the same time, I would not add it everywhere. For an internal API with three known callers, the route throttling of API Gateway is perfectly fine.
As soon as different customers, partners or AI agents share the same API, though, a per-sender limit belongs to the basic equipment for me. Agents that call tools in loops in particular create load spikes no human would ever click. A bucket per agent is then a simple and honest guardrail. If you are looking for more patterns like this, you will find them in the series “Serverless patterns in practice”.
FAQ
Can API Gateway limit requests per customer?
For REST APIs, yes, via usage plans with API keys, but only on a best-effort basis. HTTP APIs only know limits per account and Region and per stage or route, with no per-customer limits.
Which HTTP status should an API return for too many requests?
Status 429 “Too Many Requests” from RFC 6585, ideally with a Retry-After header that states the wait time in seconds. That way clients know when another attempt makes sense.
Is DynamoDB fast enough for rate limiting?
For per-account limits, almost always: a read plus a write usually take just a few milliseconds together. For very high rates on a single key, an in-memory store such as ElastiCache is a better fit.
What is the difference between token bucket and leaky bucket?
With a token bucket, unused tokens pile up to the capacity and allow bursts. A leaky bucket instead passes requests on at a steady pace and smooths out spikes rather than letting them through.
Your next step
Look at your public endpoints and ask for each one: what happens if a single client calls it a hundred times per second? Wherever the answer is “we would only notice on the bill”, the bucket from this article is a good start. I am curious: do you limit per account, per IP or per API key, and which limits have worked well for you? Let me know in the comments.
Sources
- AWS: Throttle requests to your HTTP APIs
- AWS: Usage plans and API keys for REST APIs
- AWS: DynamoDB condition expressions
- AWS: DynamoDB read and write operations (capacity, conditional writes)
- AWS: Best practices for designing partition keys
- AWS: DynamoDB API Reference – UpdateItem
- RFC 6585, section 4: 429 Too Many Requests
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)