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()) Source:https://dev.to/sascha-ms/eventbridge-replay-reprocess-events-after-a-bug-fix-7jlRelated posts
What’s !important #20: GTA 6 Scroll Effects, Scrollspies, random()

Steel beams in three.js from the same table as the weight calculator
How to Add Schema Markup in Bricks Builder: Complete WordPress Guide (2026)
Every reveal.js gap closed in Kadal Deck 0.11

