With DynamoDB TTL you can clean up expired items for free – and that is exactly why people like to use it for something it cannot do: releasing a seat right on time after ten minutes. I keep running into this misunderstanding whenever shopping carts, appointment bookings or ticket sales come up. The code looks clean, the tests are green, and in production holds suddenly stay stuck for hours.
So in this seventh part of the series I build holds with an expiry time the way I would build them today: the expiry lives in conditional writes, and TTL only cleans up. As an example, I use the ticket shop of a small cabaret theatre, and this time the infrastructure comes with the Serverless Framework. The pattern fits right in with the saga pattern from part 6: there, holds lock resources until the saga is done – and here we make sure that a stuck saga does not block anything forever.
In short
DynamoDB TTL does not delete expired items on time but eventually – according to AWS typically within a few days. That is why holds expire through their own expiry time and conditional writes; TTL and the stream only clean up afterwards. The biggest pitfall is code that silently waits for the deletion.
What you will learn in this article
- What DynamoDB TTL guarantees and what it does not
- How to hold seats all-or-nothing with a transaction
- Why the expiry check belongs in the ConditionExpression
- How TTL deletions in the stream help with cleanup
- A runnable project with the Serverless Framework and TypeScript
Why a hold needs an expiry time
Picture the ticket shop: someone picks seats B7 and B8 for the improv show on Saturday and lands in the payment step. From that moment on, nobody else may get those seats. At the same time it would be fatal to block them forever just because someone closes the browser tab or cannot find their credit card.
Chris Richardson calls such techniques “countermeasures” in his description of the saga pattern – remedies for the missing isolation of distributed workflows (microservices.io). One of them is the semantic lock: a record gets a state like “held” that other workflows respect. However, every lock has the same problem: it must reliably go away again. A lock without an expiry time is a support ticket waiting to happen.
The obvious idea is: “I just set the TTL to ten minutes and the item is gone.” That is exactly where the trouble starts. So let us first look at what TTL in DynamoDB actually promises.
What DynamoDB TTL can do – and what it cannot
TTL stands for “time to live”. You enable it per table and name an attribute in which each item carries its expiry time. According to the AWS documentation, this value must be of type Number and contain Unix epoch time in seconds. The TTL process simply ignores other data types.
The decisive sentence sits right next to it: expired items are typically deleted within a few days after their expiry. Not after seconds, not after minutes, but eventually. Until then, however, the item stays readable as usual, shows up in Query and Scan results and can even still be modified.
| Expectation | Reality according to AWS |
|---|---|
| Item disappears exactly at the TTL time | Deleted at some point afterwards, typically within a few days |
| Expired items are invisible | They show up in Query and Scan until deleted – you filter them yourself |
| Deleting costs write capacity | The TTL deletion consumes no write throughput (exception: replication in global tables) |
| Deletions look like any other in the stream | In the stream they carry the userIdentity of the service dynamodb.amazonaws.com |
The expiry time decides right away, the TTL deletion comes eventually.
This leads to the rule the rest of the article revolves around: with DynamoDB TTL there are two clocks. The first one is your business expiry time, which you check yourself on every read and write. In contrast, the second one is the DynamoDB cleanup process, and no piece of business logic may rely on it.
The pattern: expiry time for the logic, TTL for cleanup
The pattern consists of three rules. First: every hold carries an attribute expiresAt, and every decision “free or taken?” compares this attribute with the current time. Second: these comparisons live in the ConditionExpression of the write so that DynamoDB checks them atomically. Third: a separate attribute ttl makes sure that forgotten items disappear eventually.
Why two attributes if both start with the same value? Because they have different jobs. For instance, when a hold is confirmed, it should be kept as a purchase record. Then you remove ttl but leave expiresAt as information. Besides, you can later extend the cleanup period – for example to keep expired holds around for a day for debugging – without touching the logic.
The data model in one table
Specifically, we need two kinds of items in one shared table. A seat item describes one seat of a performance and carries the hold when needed. A hold item describes the hold itself: which seats, until when, in which state.
| pk | sk | Attributes | Purpose |
|---|---|---|---|
| SHOW#impro-2026-11-14 | SEAT#B7 | holdId, holdExpiresAt, soldTo | Seat with a semantic lock |
| HOLD# | HOLD | showId, seats, holdStatus, expiresAt, ttl | Hold, TTL for cleanup only |
What matters is what is missing here: seat items never get a TTL attribute. Otherwise DynamoDB would eventually delete the seat itself, and the seating plan would have a hole. The lock on the seat expires solely via holdExpiresAt.
🎬 Flow: hold, confirm, clean up – this animated diagram only runs on my site: watch it on sascha-ms.de
Step by step: building holds with DynamoDB TTL
Now it gets concrete. We build a small HTTP API with three endpoints for holding, confirming and listing seats, plus a Lambda function that cleans up after TTL deletions.
Prerequisites
- Node.js 22 or newer and npm
- an AWS account with credentials for a test environment
- Serverless Framework v4 (
npm i -g serverless), it asks for a login or license key on the first deploy - basic knowledge of Lambda and DynamoDB
Step 1: Create the project
mkdir ticket-holds && cd ticket-holds
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb
npm install -D typescript @types/aws-lambda @types/node
Afterwards the package.json looks roughly like this. We need the scripts later for type checking, tests and deploy:
{
"name": "ticket-holds",
"version": "1.0.0",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --test test/*.test.ts",
"deploy": "serverless deploy"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1159.0",
"@aws-sdk/lib-dynamodb": "^3.1159.0",
"@aws-sdk/util-dynamodb": "^3.996.9"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.164",
"@types/node": "^22.0.0",
"typescript": "^7.0.2"
}
}
The tsconfig.json is set to noEmit because Serverless Framework v4 bundles TypeScript itself with esbuild during deploy. TypeScript is only used for type checking, and the tests run directly with Node, which strips types on its own these days.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}
Step 2: Table, stream and functions in serverless.yml
The table runs in on-demand mode, TTL is set to the attribute ttl, and the stream delivers the old image of deleted items. Without OLD_IMAGE the cleanup function would not know which seats belonged to the deleted hold.
# serverless.yml
service: ticket-holds
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: eu-central-1
memorySize: 512
timeout: 10
environment:
TABLE_NAME: !Ref TicketTable
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:Query
- dynamodb:PutItem
- dynamodb:UpdateItem
Resource: !GetAtt TicketTable.Arn
functions:
createHold:
handler: src/handlers/create-hold.handler
events:
- httpApi:
method: POST
path: /shows/{showId}/holds
confirmHold:
handler: src/handlers/confirm-hold.handler
events:
- httpApi:
method: POST
path: /holds/{holdId}/confirm
listSeats:
handler: src/handlers/list-seats.handler
events:
- httpApi:
method: GET
path: /shows/{showId}/seats
releaseExpired:
handler: src/handlers/release-expired.handler
events:
- stream:
type: dynamodb
arn: !GetAtt TicketTable.StreamArn
startingPosition: LATEST
batchSize: 25
maximumRetryAttempts: 5
filterPatterns:
# only deleted hold items; the handler checks whether TTL deleted them
- eventName: [REMOVE]
dynamodb:
Keys:
sk:
S: [HOLD]
resources:
Resources:
TicketTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
- AttributeName: sk
KeyType: RANGE
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
StreamSpecification:
StreamViewType: OLD_IMAGE
The filter on the stream ensures that the cleanup function only starts for deleted hold items. Lambda can filter stream records by metadata such as eventName and by keys (Lambda docs). Whether TTL really did the deleting is checked by the handler itself as well – belt and braces.
Step 3: The core logic as pure functions
First of all, everything that builds DynamoDB expressions lives in one file without network access. In addition, the current time comes in as a parameter. As a result, every rule can be tested without an AWS account, and you can see at a glance which condition protects what.
// src/holds.ts
import type { TransactWriteCommandInput, UpdateCommandInput } from '@aws-sdk/lib-dynamodb';
/** How long a seat stays held: 10 minutes. */
export const HOLD_SECONDS = 10 * 60;
/** Seating plan of the small theatre: rows A to H, seats 1 to 20. */
const SEAT_PATTERN = /^[A-H](?:[1-9]|1[0-9]|20)$/;
const MAX_SEATS_PER_HOLD = 8;
export type SeatState = 'FREE' | 'HELD' | 'SOLD';
export interface HoldItem {
pk: string;
sk: 'HOLD';
showId: string;
seats: string[];
holdStatus: 'ACTIVE' | 'CONFIRMED';
expiresAt: number;
ttl?: number;
}
export const nowInSeconds = (): number => Math.floor(Date.now() / 1000);
export const seatKey = (showId: string, seatId: string) => ({ pk: `SHOW#${showId}`, sk: `SEAT#${seatId}` });
export const holdKey = (holdId: string) => ({ pk: `HOLD#${holdId}`, sk: 'HOLD' as const });
export function validateSeats(input: unknown): string[] {
if (!Array.isArray(input) || input.length === 0 || input.length > MAX_SEATS_PER_HOLD) {
throw new Error(`Please request 1 to ${MAX_SEATS_PER_HOLD} seats.`);
}
const seats = [...new Set(input)];
if (seats.length !== input.length) throw new Error('A seat is listed twice.');
for (const seat of seats) {
if (typeof seat !== 'string' || !SEAT_PATTERN.test(seat)) throw new Error(`Unknown seat: ${String(seat)}`);
}
return seats as string[];
}
/** Holds all seats or none: one transaction made of seat updates and the hold item. */
export function buildHoldTransaction(
tableName: string, showId: string, holdId: string, seats: string[], now: number,
): TransactWriteCommandInput {
const expiresAt = now + HOLD_SECONDS;
const seatUpdates = seats.map((seatId) => ({
Update: {
TableName: tableName,
Key: seatKey(showId, seatId),
UpdateExpression: 'SET holdId = :holdId, holdExpiresAt = :expiresAt',
// A seat is free if it is not sold and carries no valid hold.
ConditionExpression:
'attribute_not_exists(soldTo) AND (attribute_not_exists(holdExpiresAt) OR holdExpiresAt <= :now)',
ExpressionAttributeValues: { ':holdId': holdId, ':expiresAt': expiresAt, ':now': now },
ReturnValuesOnConditionCheckFailure: 'ALL_OLD' as const,
},
}));
const holdItem: HoldItem = {
...holdKey(holdId), showId, seats, holdStatus: 'ACTIVE', expiresAt,
ttl: expiresAt, // for cleanup only, not for the logic
};
return {
TransactItems: [
...seatUpdates,
{ Put: { TableName: tableName, Item: holdItem, ConditionExpression: 'attribute_not_exists(pk)' } },
],
};
}
/** Turns the hold into a purchase – only while it is still valid. */
export function buildConfirmTransaction(
tableName: string, holdId: string, hold: Pick<HoldItem, 'showId' | 'seats'>, now: number,
): TransactWriteCommandInput {
return {
TransactItems: [
{
Update: {
TableName: tableName,
Key: holdKey(holdId),
UpdateExpression: 'SET holdStatus = :confirmed, confirmedAt = :now REMOVE #ttl',
ConditionExpression: 'holdStatus = :active AND expiresAt > :now',
ExpressionAttributeNames: { '#ttl': 'ttl' },
ExpressionAttributeValues: { ':confirmed': 'CONFIRMED', ':active': 'ACTIVE', ':now': now },
},
},
...hold.seats.map((seatId) => ({
Update: {
TableName: tableName,
Key: seatKey(hold.showId, seatId),
UpdateExpression: 'SET soldTo = :holdId REMOVE holdId, holdExpiresAt',
ConditionExpression: 'holdId = :holdId AND attribute_not_exists(soldTo)',
ExpressionAttributeValues: { ':holdId': holdId },
},
})),
],
};
}
/** Frees a seat, but only if it still belongs to exactly this hold. */
export function buildSeatRelease(tableName: string, showId: string, seatId: string, holdId: string): UpdateCommandInput {
return {
TableName: tableName,
Key: seatKey(showId, seatId),
UpdateExpression: 'REMOVE holdId, holdExpiresAt',
ConditionExpression: 'holdId = :holdId',
ExpressionAttributeValues: { ':holdId': holdId },
};
}
/** Read side: expiry is judged by the clock, not by the TTL deletion. */
export function seatState(item: { soldTo?: string; holdExpiresAt?: number } | undefined, now: number): SeatState {
if (item?.soldTo) return 'SOLD';
if (item?.holdExpiresAt !== undefined && item.holdExpiresAt > now) return 'HELD';
return 'FREE';
}
/** Maps the CancellationReasons of a failed transaction to the requested seats. */
export function takenSeats(reasons: { Code?: string }[] | undefined, seats: string[]): string[] {
if (!reasons) return [];
return seats.filter((_, index) => reasons[index]?.Code === 'ConditionalCheckFailed');
}
export function allSeatIds(): string[] {
const rows = 'ABCDEFGH'.split('');
return rows.flatMap((row) => Array.from({ length: 20 }, (_, i) => `${row}${i + 1}`));
}
The most important line is the condition when holding: a seat is free if it is not sold and either carries no lock at all or its holdExpiresAt is no longer in the future. So an expired but not yet deleted hold blocks nobody. The transaction groups all seat updates and the hold item, so either all seats get held or none.
According to AWS, a TransactWriteItems request can bundle up to 100 actions with an aggregate size of at most 4 MB. With at most eight seats plus the hold item we stay far below that. Still, keep the cost in mind: DynamoDB performs every write in a transaction twice internally, once to prepare and once to commit.
Step 4: Hold seats
// src/db.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
export const TABLE_NAME = process.env.TABLE_NAME ?? '';
export const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const json = (statusCode: number, body: unknown) => ({
statusCode,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
// src/handlers/create-hold.ts
import { randomUUID } from 'node:crypto';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { TransactionCanceledException } from '@aws-sdk/client-dynamodb';
import { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
import { doc, json, TABLE_NAME } from '../db.ts';
import { buildHoldTransaction, HOLD_SECONDS, nowInSeconds, takenSeats, validateSeats } from '../holds.ts';
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
const showId = event.pathParameters?.showId ?? '';
let seats: string[];
try {
seats = validateSeats(JSON.parse(event.body ?? '{}').seats);
} catch (error) {
return json(400, { message: (error as Error).message });
}
const holdId = randomUUID();
const now = nowInSeconds();
try {
await doc.send(new TransactWriteCommand(buildHoldTransaction(TABLE_NAME, showId, holdId, seats, now)));
} catch (error) {
if (error instanceof TransactionCanceledException) {
const taken = takenSeats(error.CancellationReasons, seats);
if (taken.length > 0) return json(409, { message: 'These seats are not available right now.', seats: taken });
}
throw error; // Throttling or conflict: API Gateway returns 500, the client may retry
}
return json(201, { holdId, seats, expiresAt: new Date((now + HOLD_SECONDS) * 1000).toISOString() });
};
If the transaction fails on a condition, DynamoDB returns a TransactionCanceledException with a list of reasons – in the same order as the actions. Because the seat updates come first, takenSeats can tell directly which seats are taken right now. That is worth its weight in gold for the UI: instead of “error” it can say “sorry, B8 is gone”.
Step 5: Confirm the hold
// src/handlers/confirm-hold.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { TransactionCanceledException } from '@aws-sdk/client-dynamodb';
import { GetCommand, TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
import { doc, json, TABLE_NAME } from '../db.ts';
import { buildConfirmTransaction, holdKey, nowInSeconds, type HoldItem } from '../holds.ts';
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
const holdId = event.pathParameters?.holdId ?? '';
const { Item } = await doc.send(new GetCommand({ TableName: TABLE_NAME, Key: holdKey(holdId), ConsistentRead: true }));
const hold = Item as HoldItem | undefined;
const now = nowInSeconds();
if (!hold) return json(404, { message: 'Hold not found.' });
if (hold.holdStatus === 'CONFIRMED') return json(200, { holdId, status: 'CONFIRMED' }); // a repeated call is harmless
if (hold.expiresAt <= now) return json(410, { message: 'The hold has expired.' });
try {
await doc.send(new TransactWriteCommand(buildConfirmTransaction(TABLE_NAME, holdId, hold, now)));
} catch (error) {
if (error instanceof TransactionCanceledException) {
return json(410, { message: 'The hold has expired or was already used.' });
}
throw error;
}
return json(200, { holdId, status: 'CONFIRMED', seats: hold.seats });
};
When confirming, the transaction checks two things: the hold is still active and expiresAt lies in the future, and every seat still belongs to exactly this hold. At the same time it removes ttl so that DynamoDB does not clean up the purchase record later. A second call with the same hold simply returns the confirmed state – the same idea as with idempotent Lambda handlers.
Admittedly, the upfront check with GetCommand is only there for friendly error messages. The actual safety comes from the condition in the transaction, because time can run out between reading and writing.
Step 6: Show the seating plan
// src/handlers/list-seats.ts
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { QueryCommand } from '@aws-sdk/lib-dynamodb';
import { doc, json, TABLE_NAME } from '../db.ts';
import { allSeatIds, nowInSeconds, seatKey, seatState } from '../holds.ts';
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
const showId = event.pathParameters?.showId ?? '';
const { Items = [] } = await doc.send(new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :seat)',
ExpressionAttributeValues: { ':pk': seatKey(showId, '').pk, ':seat': 'SEAT#' },
}));
const bySeat = new Map(Items.map((item) => [String(item.sk).slice('SEAT#'.length), item]));
const now = nowInSeconds();
const seats = allSeatIds().map((seatId) => ({ seatId, state: seatState(bySeat.get(seatId), now) }));
return json(200, { showId, seats });
};
The read side also judges the time itself. For Query and Scan, the AWS docs recommend a filter expression that sorts out expired items. Here a comparison in code is enough, because we load all seats of a performance anyway and compute a state.
Step 7: Clean up after the TTL deletion
// src/handlers/release-expired.ts
import type { DynamoDBStreamEvent } from 'aws-lambda';
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import type { AttributeValue } from '@aws-sdk/client-dynamodb';
import { doc, TABLE_NAME } from '../db.ts';
import { buildSeatRelease, type HoldItem } from '../holds.ts';
/** Cleans up seats after DynamoDB TTL has deleted an expired hold. */
export const handler = async (event: DynamoDBStreamEvent): Promise<void> => {
for (const record of event.Records) {
const deletedByTtl = record.eventName === 'REMOVE' && record.userIdentity?.principalId === 'dynamodb.amazonaws.com';
const oldImage = record.dynamodb?.OldImage;
if (!deletedByTtl || !oldImage) continue;
const hold = unmarshall(oldImage as Record<string, AttributeValue>) as HoldItem;
if (hold.sk !== 'HOLD' || hold.holdStatus !== 'ACTIVE') continue;
const holdId = hold.pk.slice('HOLD#'.length);
for (const seatId of hold.seats) {
try {
await doc.send(new UpdateCommand(buildSeatRelease(TABLE_NAME, hold.showId, seatId, holdId)));
} catch (error) {
// Seat already belongs to a new hold or is sold: nothing to do.
if (!(error instanceof ConditionalCheckFailedException)) throw error;
}
}
}
};
When TTL deletes a hold, a REMOVE event appears in the stream with userIdentity.type “Service” and principalId “dynamodb.amazonaws.com”. The function then removes the lock from the seats – but only if the same holdId is still there. If someone else has held or bought the seat in the meantime, the condition fails, and that is fine.
In fact, this function is purely cosmetic. If it is down for a day, the shop still sells correctly, because every decision goes through holdExpiresAt. That is exactly what sets this pattern apart from the outbox pattern, where the stream carries the actual work.
Step 8: Test and deploy
// test/holds.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
buildConfirmTransaction, buildHoldTransaction, HOLD_SECONDS, seatState, takenSeats, validateSeats,
} from '../src/holds.ts';
const NOW = 1_800_000_000;
test('hold sets expiry time and TTL to the same moment', () => {
const tx = buildHoldTransaction('Tickets', 'impro-2026-11-14', 'h-1', ['A1', 'A2'], NOW);
const items = tx.TransactItems ?? [];
assert.equal(items.length, 3);
const put = items[2].Put!;
assert.equal(put.Item?.expiresAt, NOW + HOLD_SECONDS);
assert.equal(put.Item?.ttl, NOW + HOLD_SECONDS);
assert.match(items[0].Update!.ConditionExpression!, /holdExpiresAt <= :now/);
});
test('confirming checks the expiry time and removes the TTL', () => {
const tx = buildConfirmTransaction('Tickets', 'h-1', { showId: 'impro-2026-11-14', seats: ['A1'] }, NOW);
const holdUpdate = tx.TransactItems![0].Update!;
assert.match(holdUpdate.ConditionExpression!, /expiresAt > :now/);
assert.match(holdUpdate.UpdateExpression!, /REMOVE #ttl/);
assert.equal(tx.TransactItems![1].Update!.ExpressionAttributeValues![':holdId'], 'h-1');
});
test('expired but not yet deleted hold counts as free', () => {
assert.equal(seatState({ holdExpiresAt: NOW - 1 }, NOW), 'FREE');
assert.equal(seatState({ holdExpiresAt: NOW + 60 }, NOW), 'HELD');
assert.equal(seatState({ soldTo: 'h-1' }, NOW), 'SOLD');
assert.equal(seatState(undefined, NOW), 'FREE');
});
test('failed transaction names the taken seats', () => {
const reasons = [{ Code: 'None' }, { Code: 'ConditionalCheckFailed' }, { Code: 'None' }];
assert.deepEqual(takenSeats(reasons, ['A1', 'A2']), ['A2']);
});
test('invalid seats are rejected', () => {
assert.deepEqual(validateSeats(['B7', 'B8']), ['B7', 'B8']);
assert.throws(() => validateSeats(['Z99']), /Unknown seat/);
assert.throws(() => validateSeats(['A1', 'A1']), /twice/);
assert.throws(() => validateSeats([]), /1 to 8/);
});
Moreover, the tests run without AWS access. Start them via the scripts from package.json or directly:
npx tsc --noEmit
node --test test/*.test.ts
serverless deploy
Expected result: five green tests and, after the deploy, output with the URL of your HTTP API. Then try it by hand:
API=https://abc123.execute-api.eu-central-1.amazonaws.com
curl -s -X POST "$API/shows/impro-2026-11-14/holds" -H "content-type: application/json" -d '{"seats":["B7","B8"]}'
# {"holdId":"4f1c…","seats":["B7","B8"],"expiresAt":"2026-10-29T19:10:00.000Z"}
curl -s -X POST "$API/shows/impro-2026-11-14/holds" -H "content-type: application/json" -d '{"seats":["B8","B9"]}'
# {"message":"These seats are not available right now.","seats":["B8"]}
curl -s -X POST "$API/holds/4f1c…/confirm"
If you wait longer than ten minutes after the first call, confirming returns status 410, and B7 and B8 can be booked again right away – regardless of whether DynamoDB has already deleted the hold item or not.
Common mistakes with DynamoDB TTL
Milliseconds instead of seconds. Date.now() returns milliseconds. If that value ends up in the TTL attribute, the expiry lies millennia in the future, and the item stays forever. So calculate with Math.floor(Date.now() / 1000) everywhere and wrap it in a function like nowInSeconds.
Storing the time as a string. An ISO date in the TTL attribute looks tidy but gets ignored, because TTL only evaluates the Number type. For display purposes you are welcome to store a readable date in addition.
TTL on the wrong item. If you put the TTL attribute on the seat instead of the hold, you will eventually delete master data. Therefore the rule is simple: TTL belongs on items whose complete disappearance is fine from a business point of view.
Waiting for the stream event. A notification “your hold has expired” that is only sent after the TTL deletion may arrive two days late. If you need timely reactions, schedule them explicitly, for example with EventBridge Scheduler and a one-time schedule per hold.
Clocks without a buffer. Lambda functions run on synchronized clocks, yet I would still show the UI a slightly shorter remaining time than the real one. Anyone who sees “30 seconds left” in the browser should not already have expired on the server.
Cost and security
The TTL deletion itself costs no write capacity, but until they are gone, expired items keep adding to your storage bill and read costs. Transactions cost twice as much as simple writes, which hardly matters for a ticket shop. If you are still wondering whether DynamoDB is the right store here at all, the AWS service finder helps you compare.
On security: the functions only get GetItem, Query, PutItem and UpdateItem on exactly this table – transactions check permissions per action. The API is open in this example; in real life it needs authentication in front of it and a limit on how many holds an account may keep at the same time. Otherwise a script holds the entire house within a minute, over and over again.
My take
I like this pattern because it hard-wires an uncomfortable truth: distributed systems do not clean up on time. If you accept that, you build logic that copes with leftovers instead of logic that waits for a cleanup crew. The condition in the write is where correctness comes from – everything else is convenience.
I would push back on the common advice to always use a separate lock table or a lock library for locks. For a resource that is its own item anyway, the lock on the item itself is enough. An extra table only pays off when you lock things that do not live in DynamoDB, such as a job in a third-party system.
FAQ
How fast does DynamoDB TTL delete expired items?
There is no fixed deadline. Instead, AWS describes the deletion as happening “typically within a few days” after expiry. So never plan with a specific point in time.
Does deleting via TTL cost write capacity?
No, the TTL deletion consumes no write throughput. Only with global tables does replicating the deletion to other regions cost write units.
Can I set the TTL attribute in milliseconds?
No. The value must be a Unix time in seconds of type Number. A millisecond value would still be a number, but it would lie so far in the future that the item practically never expires.
How do I recognize TTL deletions in the DynamoDB stream?
By the field userIdentity: it has the type “Service” and the principalId “dynamodb.amazonaws.com”. Regular deletions by your code do not have this field.
Your next step
Search your own code for TTL attributes and ask yourself for each one: does a business decision depend on the item disappearing? If so, add an expiry time to the condition – usually a small change with a big effect. And I am curious: where do you use locks with an expiry time, and which pitfalls have you run into? Feel free to share in the comments.
More in this series: Previous part: Saga pattern with Step Functions · Next part: Webhook retries with SQS and Lambda · All parts of the series “Serverless patterns in practice”
Sources
- AWS: Using time to live (TTL) in DynamoDB
- AWS: Working with expired items and time to live (TTL)
- AWS: Amazon DynamoDB Transactions – How it works
- AWS: Using event filtering with a DynamoDB event source
- Chris Richardson: Pattern: Saga (microservices.io)
This post was created with AI assistance.
👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.
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)