DEV Community

Cover image for EventBridge replay: Reprocess events after a bug fix
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

EventBridge replay: Reprocess events after a bug fix

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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/);
});
Enter fullscreen mode Exit fullscreen mode
npm test
Enter fullscreen mode Exit fullscreen mode

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 }),
          },
        ],
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Timeline: bug on Oct 20, fix on Oct 27 at noon, replay ten minutes later for the highlighted 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
Enter fullscreen mode Exit fullscreen mode

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 },
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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]+$/);
});
Enter fullscreen mode Exit fullscreen mode

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)],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
npx cdk synth
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

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

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)