
Ordered, deduplicated events on the enhanced EventBridge bus
A tested CDK app on the new EventBridge bus: per-order sequencing, a FIFO subscriber feeding DynamoDB, the PutEvents trap, dedup rules and cost math.
Two updates to one order can reach a consumer in the wrong order, and a retried publish can deliver the same event twice. The enhanced Amazon EventBridge custom event bus handles EventBridge ordering, deduplication and replay on the bus. AWS announced it on 24 September 2026 in 14 Regions. We built a tested AWS Cloud Development Kit (CDK) app around it, with one publisher and two subscribers.
It's for developers who already use EventBridge and want per-order ordering without building it into every consumer. We haven't deployed anything yet. The bus behaviour in our tests comes from a local model of the documented semantics. So every result means "our code handles what the docs describe", and no more.
Key takeaways
- Set
EventGroupIdto the order ID and give the subscriberType: FIFO. Order holds within a group, and a stuck event blocks only its own group. - Send at most one entry per group in each
PutEventscall. EventBridge accepts or rejects each entry on its own, so a retried v1 can land after v2. - Send your producer's event ID as the
DeduplicationId. The window is 300 seconds and you can't change it. - Replay means a new subscriber with a past starting position. There's no replay API, so the consumer must ignore versions it already has.
- The bus bills by size. With two subscribers and 7 days kept, it costs less than the Classic bus for events of 3 KB or less (billed per whole KB).
| At a glance | |
|---|---|
| Level | Intermediate |
| Time to read | 9 minutes |
| Cost | $0 for the tests. About $2.96 a month for 10 million 1 KB events, two subscribers and 7 days of retention in us-east-1 (bus charges only) |
| Code | InfraTales/eventbridge-enhanced-bus |
| Tested with | Node.js 24, CDK 2.273.0, 131 tests (11 on DynamoDB Local), 17 mutation checks (npm run mutate), cdk-nag |
| Status | Launched 24 September 2026 in 14 Regions, per the What's New post. It isn't labelled preview, and no AWS page we read says generally available |
| Deployed | Not yet. The smoke test has never run against a real bus |
See what the enhanced bus changes
The Classic bus routes events with rules and targets. The enhanced bus uses subscribers, and each subscriber delivers to one target. The console calls the new one "Custom Event Bus" and the old one "Custom Event Bus - Classic". The command line interface (CLI) uses
eventsv2, and the CloudFormation types sit in the AWS::EventsV2 namespace.Watch the IAM prefix. The docs say IAM accepts a policy that names
eventsv2:PutEvents, but no such action exists, so the policy grants nothing. Our publisher role uses events:PutEvents on the bus's Amazon Resource Name (ARN), and one of our mutation checks proves a test fails on the wrong prefix.You can share a bus with other accounts through AWS Resource Access Manager (AWS RAM), and each consumer account owns its subscribers. The docs warn that unsharing doesn't stop a subscriber that already exists, so you revoke it. Our repo doesn't share its bus.
Follow an order through the system
The whole flow fits in one Region:

The ledger subscriber reads in order, and the analytics subscriber reads in any order. Both send events that run out of retries to dead-letter queues (DLQs), and an alarm fires on any record.
Control EventBridge ordering with event groups
Ordering lives on the subscriber. A first in, first out (FIFO) subscriber delivers events in publish order within an event group, and you set the group in
SystemMetadata.EventGroupId. The docs add that an undeliverable event holds up later events in its own group while other groups keep moving. Groups are per account, so two accounts that share a bus and reuse an ID get two groups.Our contract maps each order event to an entry like this:
1
2
3
4
5
6
7
8
9
export function toPutEventsEntry(event: OrderStatusChanged): PutEventsRequestEntry {
return {
Source: SOURCE,
DetailType: DETAIL_TYPE,
Detail: JSON.stringify(event),
Time: new Date(event.occurredAt),
SystemMetadata: { EventGroupId: event.orderId, DeduplicationId: event.eventId },
};
}The order ID is the group, so one slow order can't delay another. Keep group IDs short and bounded. One group can take 1,500 per second, counting events plus their size in KB, and the quotas page lists that limit as not adjustable.
Publish one entry per order per call
PutEvents takes 1 to 100 entries, and the docs say EventBridge accepts or rejects each entry on its own. We spotted the trap while we designed the publisher, before the first version shipped. Send v1 and v2 of one order together, let only v1 fail, retry it, and the bus now holds v2 before v1. The FIFO subscriber then delivers them in that wrong order.So the publisher sends only the head of each order's queue per call:
1
2
// The head of each order's queue, at most one per order.
const heads = [...queues.values()].filter((q) => q.length > 0).map((q) => q[0] as OrderStatusChanged);An order's next version waits until EventBridge accepts the current one. The publisher treats
DEDUPLICATED as success, because the docs call it a success code. One mutation sends a whole order's queue in one call, and three tests fail on it.Deduplicate with your own event ID
The bus drops a duplicate that arrives within 5 minutes (300 seconds), and the quotas page says you can't change that window. The check covers your account and the event group. Entries without a group still get deduplicated, across all your ungrouped entries on that bus.
You can deduplicate two ways.
DeduplicationType=CONTENT_BASED hashes the entry, and EventBridge bills it at $0.15 per million. A DeduplicationId uses a key you already have, and the docs tell you to prefer it. Our publisher sends the producer's event ID, so a retried call returns DEDUPLICATED for entries the bus already stored.Wait for Lambda and guard the write
The ledger subscriber calls an AWS Lambda function with
InvocationType: REQUEST_RESPONSE. EventBridge waits for the function and counts a thrown error as a failed delivery. With EVENT, the docs say order holds only up to Lambda's asynchronous queue. Our construct refuses to synth a FIFO subscriber with EVENT.EventBridge stops waiting after 30 seconds, the most
InvocationTimeoutSeconds allows. We set the function timeout to 20 seconds, so the function can't still be running when EventBridge marks a timeout and retries. Replays and retries deliver events again. The Amazon DynamoDB write applies only newer versions:1
2
3
ConditionExpression: 'attribute_not_exists(orderId) OR #version < :version',
ExpressionAttributeNames: { '#version': 'version' },
ExpressionAttributeValues: { ':version': event.version },A failed condition returns
stale instead of an error, so a duplicate counts as a success. We ran this against DynamoDB Local with 20 concurrent writers. The function also applies a batch one event at a time, because a parallel loop would throw away the order inside the batch.Shape analytics events with JSONata
The analytics subscriber is
UNORDERED, and it reshapes each event into a flat record for an Amazon Simple Queue Service (SQS) queue. The expression goes inside {% %} and reads the event from $events:1
2
3
4
5
6
export const ANALYTICS_JSONATA =
'{% {' +
'"orderId": $events.Data.detail.orderId, ' +
'"status": $events.Data.detail.status, ' +
'"replay": $events.SystemMetadata."aws:DeliveryType" = "REPLAY"' +
'} %}';We trimmed the real expression to three fields here.
PutEvents data sits under Data.detail, and a filter or expression written for the wrong shape matches nothing without an error. We tested the expression with the open-source jsonata library, not EventBridge's engine.Both subscribers filter on
aws:Source in the SYSTEM_METADATA scope. EventBridge writes that field, and no publisher can set a source that starts with aws..Set retries and a dead-letter queue
The default retry policy is 5 attempts or 300 seconds. A Classic target gets 24 hours and 185 attempts. Without a DLQ, the docs say EventBridge drops an event that runs out of retries, and only the
EventsDropped metric shows it.We gave the ledger 10 attempts or 1 hour, since a failing event blocks its order for that long. Analytics gets 185 attempts or 24 hours. The docs say a FIFO subscriber needs a FIFO queue as its DLQ. A DLQ holds a record with the event ID, not the event, so you redrive by replaying from the bus.
Run an EventBridge replay from retention
The bus keeps events for 1 to 365 days, with a default of 1. Ours keeps 7. A subscriber reads retained events from its starting position:
LATEST, or POINT_IN_TIME with HORIZON or a TIMESTAMP. The docs describe a startup delay, then delivery at live speed, with aws:DeliveryType set to REPLAY.Deploy with
-c replayFromEpochSeconds=<unix seconds> to add a second FIFO subscriber that feeds the same ledger. The version check skips events the ledger already has.Watch the create-only properties. Changing
Type or StartingPosition makes CloudFormation delete the old subscriber before it creates the new one. A new LATEST subscriber misses the events published in that gap.Declare the resources with CfnResource
We assumed CDK 2.273.0 had no level 1 (L1) classes for these types. It does: the
aws-eventsv2 module has CfnEventBus and CfnSubscriber. But CfnSubscriber has no invokeConfiguration property, and CloudFormation requires InvokeConfiguration.So the repo uses
cdk.CfnResource with the property names from the CloudFormation reference:1
2
3
4
5
6
7
8
9
10
11
this.resource = new cdk.CfnResource(this, 'Resource', {
type: 'AWS::EventsV2::Subscriber',
properties: {
Name: props.name,
EventBusArn: props.eventBusArn,
Type: props.type,
InvokeConfiguration: { TargetArn: invoke.targetArn, RoleArn: invoke.roleArn /* ... */ },
RetryPolicy: { MaxRetryAttempts: retry.maxRetryAttempts, MaxEventAgeInSeconds: retry.maxEventAgeInSeconds },
OnFailureConfiguration: { Arn: props.deadLetterQueueArn },
},
});The typed wrapper in
infra/lib/eventsv2.ts checks documented limits at synth time. For example, it allows 0 to 185 retries and one filter per scope.What we got wrong first: the dedup test
Our first dedup-window test imported
DEDUP_WINDOW_MS from the model it was testing. We changed the model's window to 10 minutes, and the mutant survived: every test still passed, because the test moved with the model.The test now writes the documented value out:
1
2
/** The documented window: "within 5 minutes (300 seconds)". Written out, not imported, so a change to the model's constant fails a test. */
const DEDUP_WINDOW_MS = 300_000;The 10-minute mutant now fails the "after the window" test. A test that reads its expected value from the code under test can't catch a wrong value.
Compare the cost with the Classic bus
The enhanced bus charges $0.18 per GB published, in the first 5,000 GB, and $0.05 per GB per subscriber delivered. Retention past the first 24 hours costs $0.08 per GB-month. The Classic bus charges $1.00 per million events, counting each 64 KB as one, and same-account delivery is free.
The two parts of the pricing page use different GB units, and we follow each one. The enhanced example treats 1 GB as 1,000,000 KB. The Classic archive example divides KB by 1024 × 1024. The math for 10 million 1 KB events, two subscribers and 7 days kept:
| Line | Enhanced bus | Classic bus |
|---|---|---|
| Publish | 10 GB × $0.18 = $1.80 | 10M × $1.00 per million = $10.00 |
| Deliver to 2 subscribers | 20 GB × $0.05 = $1.00 | Free in the same account |
| Keep 7 days | 2 GB × $0.08 = $0.16 | Archive 9.54 GB × $0.10 = $0.95, plus $0.05 storage for 7 days |
| Replay the last 7 days | 2.33 GB × $0.05 = $0.12, assumed | 2.33M × $1.00 per million = $2.33 |
| Total | $2.96, or $3.08 with the replay | $10.00, or $13.34 with archive and the replay |
Both buses hold 7 days here. So the replay row reads back the last 7 days, about 2.33 million events. No AWS page says how AWS bills a replay on the enhanced bus. We assume the replaying subscriber pays egress at $0.05 per GB and nothing more. The Classic page says replayed events bill as new custom events.
Retention follows the pricing page's method. Ten GB over 30 days is 0.33 GB a day, and 6 days past the first makes 2 GB. JSONata on one subscriber adds $1.50. At 16 KB per event the enhanced bus costs $47.36, and the Classic bus stays at $10.00. The Price List also has a $0.08 per GB-month "SubscriberRetention" line that the pricing page doesn't explain, so we left it out.
When this isn't the right fit
- Your events are large. Above 3 KB (billed per whole KB) with two subscribers, a Classic bus with no archive costs less per event. If Classic also archives 7 days for replay, the enhanced bus stays cheaper up to about 5 KB.
- You need a Region outside the 14 launch Regions in the What's New post.
- Your consumer can't ignore a version it already has. Replays and retries will deliver events again.
- You need a construct library today. CDK has no level 2 (L2) constructs, and the L1 subscriber can't set its target.
Try it yourself
You need Node.js 24, plus Docker for the DynamoDB Local tests:
1
2
3
4
5
6
npm ci
npm run check # types, lint, unit and infra tests, synth with cdk-nag
docker run -d --rm -p 8025:8000 amazon/dynamodb-local:3.3.2
AWS_ENDPOINT_URL_DYNAMODB=http://localhost:8025 npm run test:int
AWS_ENDPOINT_URL_DYNAMODB=http://localhost:8025 npm run mutate # 17 mutants, each must fail a test
npm run cost # the cost tables aboveTo deploy, use a CDK-bootstrapped account in a Region with the enhanced bus, such as us-east-1. Run
npm run deploy, then npm run smoke, which publishes twice and checks dedup, the ledger and the queue. We haven't run it, so tell us what you see.Clean up
Run
npm run destroy. It should delete the bus, both subscribers, the queues, the log groups and the ledger table, which has a destroy removal policy. We haven't run it, so check the CloudFormation console afterwards.Do any events in your system need per-entity order, and how do you keep them in order today?
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article