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

Timeline: hold valid until expiresAt, then expired but readable, TTL deletes it minutes to days later

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'; Source:https://dev.to/sascha-ms/dynamodb-ttl-build-seat-holds-that-expire-c9d

Related posts