EventBridge replay is the tool I wish I had had in my first larger event-driven system. Anyone who works with events knows the scenario: a consumer has a bug, processes events incorrectly for days, and after the fix you face the awkward question of how to put the affected data right again. Without an archive, all you have left are logs, scripts against the database and a lot of hope.
In this article you build, step by step, a setup where that is no longer a drama. You create an archive on your own event bus, write a consumer that copes with repeated events, and after a bug fix you replay a specific time range again, only for the one rule it affects. The infrastructure comes with the Serverless Framework and, for comparison, with AWS CDK; the code is TypeScript.
In short
An EventBridge archive stores the events of a bus, and a replay sends a chosen time range of them back to that same bus. That lets you correct data that was processed incorrectly before a bug fix, without the producers having to do anything. The biggest pitfall: without a rule filter, the old events reach every consumer, including the ones that send emails or trigger payments.
What you will learn in this article
- How archives and replays work together in EventBridge and where their limits are
- Creating an archive with the Serverless Framework and with AWS CDK
- Writing a Lambda consumer that handles replays safely
- Limiting a replay to one rule via the CLI and a TypeScript script
- What changes with the new Custom Event Bus from 2026
What are EventBridge archive and replay?
Amazon EventBridge is the event bus of AWS: producers send events to a bus, rules filter them with an event pattern and forward them to targets such as Lambda, SQS or Step Functions. Normally an event is gone once it has been delivered. The bus remembers nothing.
An archive changes that. You attach it to exactly one bus, optionally define an event pattern and set how many days the events are kept. Without a value, EventBridge keeps them indefinitely. A replay then takes a time range from this archive and sends the events to the source bus again. You can decide that only specific rules receive them.
You should know a few properties before you rely on it. Events reach the archive with a slight delay, so AWS recommends waiting at least ten minutes before replaying a time range. Order is not guaranteed: EventBridge replays minute by minute, first all events of the first minute, then those of the second. In addition, the events stay in the archive after a replay, so you can replay the same range several times.
How does a consumer recognize a repeated event?
EventBridge adds the field replay-name with the name of the replay to every event it sends again. Your code can react to it, for example by skipping follow-up actions. The archive itself uses the same field: when you create one, EventBridge adds a managed rule to the bus that filters out all events with replay-name. That way a replayed event never lands in the archive a second time.
🎬 How an EventBridge replay works – this animated diagram only runs on my site: watch it on sascha-ms.de
What a replay does not replace
A replay is no substitute for retries or a dead-letter queue. If a delivery fails briefly, the rule’s retry policy and a DLQ make sure nothing gets lost. I showed how to catch Lambda invocations that fail for good in the article on Lambda Destinations. The archive helps you in a different case: delivery worked, but your code did something wrong along the way.
Does this also apply to the new Custom Event Bus?
Since September 2026, EventBridge has two kinds of custom buses. The existing one is now called “Custom Event Bus – Classic” and keeps working with rules, targets and archives. The new “Custom Event Bus” comes with built-in retention of 1 to 365 days, and replays there work through a starting position per subscriber instead of an archive. At launch it is available in fourteen Regions, including Frankfurt.
| Question | Custom Event Bus – Classic | New Custom Event Bus |
|---|---|---|
| Keeping events | only with an archive | built in, 1–365 days |
| Replay | from the archive to the source bus | starting position of a subscriber |
| Routing | rules with up to five targets | subscriber with exactly one target |
| Ordering | not guaranteed | FIFO within an event group |
According to AWS, all existing APIs remain unchanged. So if you use rules on the default bus or on a classic bus today, which is probably true for most Serverless applications, the archive is still the way to replay. That is what this article is about. I will take a closer look at the new bus in a separate post.
The example: late fees at the city library
The example is a made-up city library. Whenever an item is returned, the loan system sends an ItemReturned event with loan ID, member, due date and return time to the bus. A Lambda function calculates the late fee from it, 50 cents per started day and at most 10 euros, and stores it in DynamoDB.
The first version of the function had a typical bug: it calculated with milliseconds in UTC instead of calendar days in German time. Anyone who brought a book back on the due date at 11 pm was still charged 50 cents. The fix is written quickly. The real question is how to correct last week’s wrong fees without the loan system having to send anything again.
Setting up EventBridge replay step by step
Prerequisites
- Node.js 22 or later and an AWS account with permissions for EventBridge, Lambda, DynamoDB and CloudFormation
- Serverless Framework v4 (
npm i -g serverless) or alternatively AWS CDK v2 - AWS CLI v2 for the tests and the command-line replay
- Some experience with Lambda and EventBridge rules, for example from the article SQS and EventBridge with Lambda
Step 1: Create the project
Create a folder and install the dependencies. For the tests I use the built-in Node.js test runner, so TypeScript is enough without any extra framework.
mkdir library-late-fees && cd library-late-fees
npm init -y
npm i @aws-sdk/client-dynamodb @aws-sdk/client-eventbridge
npm i -D typescript @types/node @types/aws-lambda
In package.json you set "type": "module" and three scripts. Node.js 22 can run TypeScript files directly with --experimental-strip-types, so tests and scripts need no build step.
{
"type": "module",
"scripts": {
"build": "tsc --noEmit",
"test": "node --test --experimental-strip-types test/*.test.ts",
"replay": "node --experimental-strip-types scripts/replay.ts"
}
}
Step 2: Bus, archive and rule in serverless.yml
The bus, the archive and the DynamoDB table are CloudFormation resources in the resources section. The Serverless Framework has no dedicated syntax for archives, but AWS::Events::Archive works there without detours. The rule name set via name matters: you need it later to limit the replay to exactly this rule.
# serverless.yml
service: library-late-fees
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: eu-central-1
eventBridge:
useCloudFormation: true
environment:
FEE_TABLE: !Ref LateFeesTable
BUS_NAME: !Ref LibraryBus
iam:
role:
statements:
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt LateFeesTable.Arn
- Effect: Allow
Action: events:PutEvents
Resource: !GetAtt LibraryBus.Arn
functions:
calculateLateFee:
handler: src/handler.handler
events:
- eventBridge:
name: library-late-fee-${sls:stage}
eventBus: !GetAtt LibraryBus.Arn
pattern:
source:
- library.loans
detail-type:
- ItemReturned
resources:
Resources:
LibraryBus:
Type: AWS::Events::EventBus
Properties:
Name: library-events-${sls:stage}
LoansArchive:
Type: AWS::Events::Archive
Properties:
ArchiveName: library-loans-${sls:stage}
Description: City library loan events for replays
SourceArn: !GetAtt LibraryBus.Arn
RetentionDays: 30
EventPattern:
source:
- library.loans
LateFeesTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: loanId
AttributeType: S
KeySchema:
- AttributeName: loanId
KeyType: HASH
Outputs:
ArchiveName:
Value: !Ref LoansArchive
BusName:
Value: !Ref LibraryBus
The archive stores only events with the source library.loans and keeps them for 30 days. That is a deliberate decision. The follow-up events of the fee function (source library.fees) do not belong in the archive, because they can always be derived again from the loan events. Archive the facts, not the results.
Step 3: The fee logic as a pure function
The calculation lives in its own file without AWS dependencies. That makes it easy to test, and for the bug fix you change exactly one place. The constant FEE_RULES_VERSION goes up whenever the rule changes. You will see why in the handler in a moment.
// src/late-fee.ts
// City library fee rule: 50 cents per started overdue day, at most 10 euros.
export const FEE_RULES_VERSION = 2;
const CENTS_PER_DAY = 50;
const MAX_FEE_CENTS = 1000;
const TIME_ZONE = 'Europe/Berlin';
// Returns the calendar date (YYYY-MM-DD) of a timestamp in German time.
export function localDate(isoTimestamp: string): string {
const date = new Date(isoTimestamp);
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid timestamp: ${isoTimestamp}`);
}
return new Intl.DateTimeFormat('en-CA', {
timeZone: TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
// Counts the calendar days between two dates in YYYY-MM-DD format.
function daysBetween(fromDate: string, toDate: string): number {
const msPerDay = 24 * 60 * 60 * 1000;
return Math.round((Date.parse(toDate) - Date.parse(fromDate)) / msPerDay);
}
// Version 2: compares calendar days instead of milliseconds.
// Returning on the due date costs nothing, whatever the time of day.
export function calculateLateFeeCents(dueDate: string, returnedAt: string): number {
const overdueDays = daysBetween(dueDate, localDate(returnedAt));
if (overdueDays <= 0) {
return 0;
}
return Math.min(overdueDays * CENTS_PER_DAY, MAX_FEE_CENTS);
}
The tests cover exactly the case that went wrong in version 1: a late return on the due date and a return shortly after midnight German time.
// test/late-fee.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateLateFeeCents, localDate } from '../src/late-fee.ts';
test('returning on the due date costs nothing, even late in the evening', () => {
// 23:30 German time on Oct 20 is 21:30 UTC
assert.equal(calculateLateFeeCents('2026-10-20', '2026-10-20T21:30:00Z'), 0);
});
test('shortly after midnight German time, the next day already counts', () => {
assert.equal(localDate('2026-10-20T22:30:00Z'), '2026-10-21');
assert.equal(calculateLateFeeCents('2026-10-20', '2026-10-20T22:30:00Z'), 50);
});
test('three days late costs 1.50 euros', () => {
assert.equal(calculateLateFeeCents('2026-10-20', '2026-10-23T09:00:00Z'), 150);
});
test('the fee is capped at 10 euros', () => {
assert.equal(calculateLateFeeCents('2026-09-01', '2026-10-27T10:00:00Z'), 1000);
});
test('invalid timestamps throw an error', () => {
assert.throws(() => calculateLateFeeCents('2026-10-20', 'yesterday'), /Invalid timestamp/);
});
npm test
Expected result: all tests pass, and the output shows # pass 9 and # fail 0 once you have also added the replay tests from step 7.
Step 4: A handler that copes with replays
Now comes the most important part. A consumer that can receive replays has to meet three requirements. It must be idempotent, meaning it returns the same result for the same input no matter how often it runs. It should rely only on data from the event, not on the current time. And during a replay it must not trigger side effects that have already happened once.
// src/handler.ts
import { ConditionalCheckFailedException, DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
import type { EventBridgeEvent } from 'aws-lambda';
import { FEE_RULES_VERSION, calculateLateFeeCents } from './late-fee.ts';
export interface ItemReturned {
loanId: string;
memberId: string;
dueDate: string; // YYYY-MM-DD
returnedAt: string; // ISO-8601
}
// During a replay, EventBridge adds the "replay-name" field.
export type LibraryEvent = EventBridgeEvent<'ItemReturned', ItemReturned> & { 'replay-name'?: string };
const dynamo = new DynamoDBClient({});
const eventBridge = new EventBridgeClient({});
const tableName = process.env.FEE_TABLE ?? '';
const busName = process.env.BUS_NAME ?? '';
export async function handler(event: LibraryEvent): Promise<void> {
const { loanId, memberId, dueDate, returnedAt } = event.detail;
const replayName = event['replay-name'];
const feeCents = calculateLateFeeCents(dueDate, returnedAt);
try {
// Idempotent: the same input always produces the same record.
// The condition stops older fee rules from overwriting a newer result.
await dynamo.send(
new PutItemCommand({
TableName: tableName,
Item: {
loanId: { S: loanId },
memberId: { S: memberId },
feeCents: { N: String(feeCents) },
feeRulesVersion: { N: String(FEE_RULES_VERSION) },
calculatedBy: { S: replayName ?? 'live' },
calculatedAt: { S: new Date().toISOString() },
},
ConditionExpression: 'attribute_not_exists(loanId) OR feeRulesVersion <= :version',
ExpressionAttributeValues: { ':version': { N: String(FEE_RULES_VERSION) } },
}),
);
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
console.log(JSON.stringify({ msg: 'Newer fee rule already stored, skipped', loanId }));
return;
}
throw error;
}
// Send follow-up events only for live traffic, never during a replay.
if (replayName) {
console.log(JSON.stringify({ msg: 'Replay: fee corrected, no follow-up event', loanId, feeCents, replayName }));
return;
}
if (feeCents > 0) {
await eventBridge.send(
new PutEventsCommand({
Entries: [
{
EventBusName: busName,
Source: 'library.fees',
DetailType: 'LateFeeCharged',
Detail: JSON.stringify({ loanId, memberId, feeCents }),
},
],
}),
);
}
}
The condition in PutItem is a small but important safeguard. It allows overwriting only if the stored record was calculated with the same or an older rule version. If someone accidentally replays with an old version of the code, the corrected values stay in place. You can find more on the basics in the article Idempotency in AWS Lambda.
The follow-up event LateFeeCharged goes out only for live traffic. During a replay, the function writes the corrected fee and records the replay name in the calculatedBy field. Later you can see in the table which records a replay touched.
Step 5: Deploy and test
serverless deploy --stage dev
aws events put-events --entries '[{"EventBusName":"library-events-dev","Source":"library.loans","DetailType":"ItemReturned","Detail":"{\"loanId\":\"L-1001\",\"memberId\":\"M-42\",\"dueDate\":\"2026-10-20\",\"returnedAt\":\"2026-10-23T09:00:00Z\"}"}]'
aws dynamodb scan --table-name <table name from the deploy output>
Afterwards the table contains an entry with feeCents 150 and calculatedBy “live”. After a few minutes, use aws events describe-archive --archive-name library-loans-dev to check that the archive exists and has the state ENABLED. According to the docs, the fields EventCount and SizeBytes are only reconciled within 24 hours, so a zero right after the test is not an error.
Starting the EventBridge replay after the bug fix
Let’s assume version 1 ran from October 20 until the fix was deployed on October 27 at noon. Every return in that period may have been calculated wrongly. After deploying version 2, you replay exactly this window.
The replay window covers the time the faulty version was running.
Step 6: Replay with the AWS CLI
For a one-off replay, the CLI is enough. You get the rule ARN with describe-rule and the archive ARN with describe-archive. FilterArns is what matters: without this filter, every rule on the bus receives the old events.
aws events describe-rule --name library-late-fee-dev --event-bus-name library-events-dev --query Arn
aws events describe-archive --archive-name library-loans-dev --query ArchiveArn
aws events start-replay \
--replay-name fix-overdue-days-1 \
--event-source-arn arn:aws:events:eu-central-1:111122223333:archive/library-loans-dev \
--event-start-time 2026-10-20T00:00:00Z \
--event-end-time 2026-10-27T12:00:00Z \
--destination '{"Arn":"arn:aws:events:eu-central-1:111122223333:event-bus/library-events-dev","FilterArns":["arn:aws:events:eu-central-1:111122223333:rule/library-events-dev/library-late-fee-dev"]}'
aws events describe-replay --replay-name fix-overdue-days-1
The account number 111122223333 is a placeholder. describe-replay shows the state (STARTING, RUNNING, COMPLETED …) and, with EventLastReplayedTime, the time of the most recently replayed event. That tells you how far it has progressed. If something goes wrong, stop the replay with aws events cancel-replay while it is still starting or running.
Step 7: A replay script in TypeScript with guardrails
If replays happen more often or someone else on the team starts them, a small script pays off. The checks live in a pure function: an end time at least ten minutes in the past, a start before the end and always at least one rule as a filter. The replay name is built from a reason and a timestamp and follows the API rules (at most 64 characters, only letters, digits, dot, hyphen and underscore).
// src/replay-plan.ts
import type { StartReplayCommandInput } from '@aws-sdk/client-eventbridge';
// AWS recommends waiting about 10 minutes because events reach the archive with a delay.
export const ARCHIVE_DELAY_MS = 10 * 60 * 1000;
export interface ReplayPlanInput {
archiveArn: string;
busArn: string;
ruleArns: string[];
from: Date;
to: Date;
now: Date;
label: string;
}
// Replay names: 1–64 characters, letters, digits, dot, hyphen and underscore are allowed.
export function buildReplayName(label: string, now: Date): string {
const slug = label.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'replay';
const stamp = now.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
return `${slug.slice(0, 64 - stamp.length - 1)}-${stamp}`;
}
export function buildReplayRequest(input: ReplayPlanInput): StartReplayCommandInput {
const { archiveArn, busArn, ruleArns, from, to, now, label } = input;
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new Error('Start and end time must be valid timestamps.');
}
if (from >= to) {
throw new Error('The start time must be before the end time.');
}
if (to.getTime() > now.getTime() - ARCHIVE_DELAY_MS) {
throw new Error('The end time must be at least 10 minutes in the past.');
}
if (ruleArns.length === 0) {
throw new Error('Specify at least one rule, otherwise every rule on the bus receives the events.');
}
return {
ReplayName: buildReplayName(label, now),
Description: `Replay ${from.toISOString()} to ${to.toISOString()}`,
EventSourceArn: archiveArn,
EventStartTime: from,
EventEndTime: to,
Destination: { Arn: busArn, FilterArns: ruleArns },
};
}
The script looks up the archive and rules, prints the plan and only starts the replay without --dry-run. After that it polls the state every 15 seconds.
// scripts/replay.ts
// Usage: npm run replay -- --archive library-loans-dev --rule library-late-fee-dev \
// --from 2026-10-20T00:00:00Z --to 2026-10-27T12:00:00Z --label fix-overdue-days [--dry-run]
import { parseArgs } from 'node:util';
import {
DescribeArchiveCommand,
DescribeReplayCommand,
DescribeRuleCommand,
EventBridgeClient,
StartReplayCommand,
} from '@aws-sdk/client-eventbridge';
import { buildReplayRequest } from '../src/replay-plan.ts';
const { values } = parseArgs({
options: {
archive: { type: 'string' },
rule: { type: 'string', multiple: true },
from: { type: 'string' },
to: { type: 'string' },
label: { type: 'string', default: 'replay' },
'dry-run': { type: 'boolean', default: false },
},
});
if (!values.archive || !values.rule || !values.from || !values.to) {
console.error('Required: --archive, --rule (can be repeated), --from, --to');
process.exit(1);
}
const client = new EventBridgeClient({});
// 1. Look up the archive: returns the archive ARN and the source bus.
const archive = await client.send(new DescribeArchiveCommand({ ArchiveName: values.archive }));
const busArn = archive.EventSourceArn!;
const busName = busArn.split('/').pop()!;
// 2. Resolve rule ARNs so that only these rules receive the events again.
const ruleArns: string[] = [];
for (const name of values.rule) {
const rule = await client.send(new DescribeRuleCommand({ Name: name, EventBusName: busName }));
ruleArns.push(rule.Arn!);
}
const request = buildReplayRequest({
archiveArn: archive.ArchiveArn!,
busArn,
ruleArns,
from: new Date(values.from),
to: new Date(values.to),
now: new Date(),
label: values.label,
});
console.log('Replay plan:', JSON.stringify(request, null, 2));
if (values['dry-run']) {
process.exit(0);
}
// 3. Start the replay and poll its progress.
const started = await client.send(new StartReplayCommand(request));
console.log(`Started: ${request.ReplayName} (${started.State})`);
const terminal = new Set(['COMPLETED', 'FAILED', 'CANCELLED']);
let state: string = started.State ?? 'STARTING';
while (!terminal.has(state)) {
await new Promise((resolve) => setTimeout(resolve, 15_000));
const status = await client.send(new DescribeReplayCommand({ ReplayName: request.ReplayName }));
state = status.State ?? 'UNKNOWN';
console.log(`${state}, last replayed: ${status.EventLastReplayedTime?.toISOString() ?? '–'}`);
if (status.StateReason) {
console.log(`Reason: ${status.StateReason}`);
}
}
process.exit(state === 'COMPLETED' ? 0 : 1);
npm run replay -- --archive library-loans-dev --rule library-late-fee-dev \
--from 2026-10-20T00:00:00Z --to 2026-10-27T12:00:00Z --label fix-overdue-days --dry-run
The tests for the planning function check the rule filter, the ten-minute limit and the name length:
// test/replay-plan.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildReplayName, buildReplayRequest } from '../src/replay-plan.ts';
const base = {
archiveArn: 'arn:aws:events:eu-central-1:111122223333:archive/library-loans-dev',
busArn: 'arn:aws:events:eu-central-1:111122223333:event-bus/library-events-dev',
ruleArns: ['arn:aws:events:eu-central-1:111122223333:rule/library-events-dev/library-late-fee-dev'],
now: new Date('2026-10-28T12:00:00Z'),
label: 'Fix overdue days',
};
test('builds a replay request for the given rule only', () => {
const request = buildReplayRequest({
...base,
from: new Date('2026-10-20T00:00:00Z'),
to: new Date('2026-10-27T12:00:00Z'),
});
assert.equal(request.ReplayName, 'fix-overdue-days-20261028-120000');
assert.deepEqual(request.Destination, { Arn: base.busArn, FilterArns: base.ruleArns });
});
test('rejects end times less than 10 minutes ago', () => {
assert.throws(
() => buildReplayRequest({ ...base, from: new Date('2026-10-28T10:00:00Z'), to: new Date('2026-10-28T11:55:00Z') }),
/at least 10 minutes/,
);
});
test('rejects a replay without a rule filter', () => {
assert.throws(
() => buildReplayRequest({ ...base, ruleArns: [], from: new Date('2026-10-20T00:00:00Z'), to: new Date('2026-10-21T00:00:00Z') }),
/at least one rule/,
);
});
test('replay names stay within 64 characters and match the pattern', () => {
const name = buildReplayName('A very long reason for replaying all the loans from October once more', base.now);
assert.ok(name.length <= 64);
assert.match(name, /^[.\-_A-Za-z0-9]+$/);
});
What to watch during the replay
Keep an eye on the function’s metrics, especially errors and throttles. A replay can deliver many events in a short time. According to the docs, the pace is based on your account’s PutEvents limit but counted separately from it. For a function with limited concurrency or a slow third-party system behind it, that is a real load test. In the logs you will find the lines with the replay name, and in DynamoDB calculatedBy shows which records were corrected.
The same with AWS CDK
In CDK, the archive is a method on the bus. Everything else matches serverless.yml, except that the permissions come from grantWriteData and grantPutEventsTo.
// lib/library-stack.ts
import { Duration, Stack, type StackProps } from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import type { Construct } from 'constructs';
export class LibraryStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const bus = new events.EventBus(this, 'LibraryBus', { eventBusName: 'library-events-dev' });
// Archive right on the bus: loan events only, 30-day retention.
bus.archive('LoansArchive', {
archiveName: 'library-loans-dev',
description: 'City library loan events for replays',
eventPattern: { source: ['library.loans'] },
retention: Duration.days(30),
});
const table = new dynamodb.TableV2(this, 'LateFees', {
partitionKey: { name: 'loanId', type: dynamodb.AttributeType.STRING },
});
const fn = new NodejsFunction(this, 'CalculateLateFee', {
entry: 'src/handler.ts',
runtime: lambda.Runtime.NODEJS_22_X,
architecture: lambda.Architecture.ARM_64,
environment: { FEE_TABLE: table.tableName, BUS_NAME: bus.eventBusName },
});
table.grantWriteData(fn);
bus.grantPutEventsTo(fn);
new events.Rule(this, 'LateFeeRule', {
ruleName: 'library-late-fee-dev',
eventBus: bus,
eventPattern: { source: ['library.loans'], detailType: ['ItemReturned'] },
targets: [new targets.LambdaFunction(fn)],
});
}
}
npx cdk synth
npx cdk deploy
The synthesized template then contains a resource of type AWS::Events::Archive with RetentionDays: 30 and the event pattern for library.loans. The replay script works with both variants because the archive and rule names are identical.
Common EventBridge replay mistakes
Replaying without a rule filter
The most expensive mistake. Without FilterArns, the events go to every rule on the bus, including the service that sends reminder emails. So always name the rules explicitly; the script above enforces it.
Replaying too early
Events that have only just arrived may not be in the archive yet. If your time window ends less than ten minutes ago, you may miss the last events without any error message.
Relying on order
Within a minute, the order is open. If your consumer expects “borrowed” to always arrive before “returned”, a replay will prove it wrong sooner or later. Build consumers so that each event can be processed on its own, or check versions and timestamps in the record.
An archive without a pattern
Without an event pattern, every event on the bus ends up in the archive, including derived events. That costs storage and makes a replay confusing. Archive the source events from which everything else can be derived.
Forgetting encryption
By default, EventBridge encrypts the archive with an AWS owned key. If your bus uses its own KMS key, AWS explicitly recommends specifying a customer managed key for the archive as well via KmsKeyIdentifier.
How much does an EventBridge archive cost?
For archives you pay for processing when events are archived and for storage. The AWS pricing example lists 0.10 US dollars per GB of archive processing and 0.023 US dollars per GB per month for storage. Replayed events are billed like custom events, 1.00 US dollar per million in the example. For 2 million events of 6 KB each, AWS arrives at about 1.40 US dollars a month for archive and storage. For most applications, that is a small price for being able to correct mistakes cleanly.
Still, limit retention to what you realistically need. 30 days is enough if you usually notice bugs within a week. If your events contain personal data, short retention also makes sense from a privacy point of view. An indefinite archive full of names and addresses is hard to justify. By the way, you can look up which service fits which problem in the AWS service finder.
My take
I consider an archive on every bus with business events a must, not an extra. The cost is low, and the day you need it will come. However, a replay is only as good as the consumers that receive it. If you add idempotency and handling of replay-name only after the bug has happened, you have one more problem instead of one less.
One expectation I would like to dampen: an archive is not a database. Retention is limited, order is not guaranteed, and you cannot search for a specific event. For quick corrections after a bug fix and for feeding new consumers with the events of the last few days, it is ideal. Anything that needs a complete, queryable history belongs in its own store. I find it notable that AWS narrows exactly this gap a little with the new Custom Event Bus.
FAQ
Can I send events from an archive to a different bus?
No. A replay always goes to the bus the archive receives its events from. You can, however, create a rule on that bus that forwards events to another bus and limit the replay to that rule.
How long does EventBridge keep archived events?
As long as you configure on the archive. Without a value, or with 0, events are kept indefinitely. A replay deletes nothing from the archive; events disappear only when the retention period expires or you delete the archive.
How many replays can run at the same time?
Each account can have at most ten active replays per Region at the same time. EventBridge deletes completed replays after 90 days.
Do archives also work on the default bus?
Yes. You can also create an archive for the default bus, for example for events from AWS services. An event pattern is particularly useful there because many events arrive on the default bus.
Your next step
Take a look at your most important event bus today and check whether it has an archive. If it does not, the few lines for the archive from step 2 are enough. Then pick a consumer and ask yourself honestly: what would happen if it received last week’s events once more? Would it send duplicate emails, trigger payments or simply write the same data again? Feel free to tell me in the comments which answer you found.
For another example of events where you are better off checking the current state than trusting the event blindly, see my article Identity Center EventBridge: automate offboarding.
If your events come from a schedule rather than a business system, take a look at EventBridge Scheduler with a time zone: it keeps cron jobs at the usual local time even after the clocks change.
Sources
- Amazon EventBridge User Guide: Archiving and replaying events
- Amazon EventBridge User Guide: Creating replays of archived events
- Amazon EventBridge API Reference: StartReplay
- AWS CloudFormation: AWS::Events::Archive
- Amazon EventBridge Pricing (Event replay)
- Amazon EventBridge User Guide: What is the EventBridge Custom Event Bus?
- AWS What’s New: Amazon EventBridge relaunches event buses for enterprise scale (Sep 24, 2026)
- Serverless Framework: EventBridge Event
- Tested with Node.js 22.23, TypeScript 7.0.2, node:test (9 tests), @aws-sdk/client-eventbridge and @aws-sdk/client-dynamodb 3.1158.0, and aws-cdk-lib 2.275.0 (
cdk synthwithAWS::Events::Archive). Theserverless.ymlresources were checked with cfn-lint, not deployed.
This post was created with AI assistance.
👉 Next step: Need a schedule in EventBridge? My EventBridge cron helper builds and explains the expression for you.
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)