EventBridge: Decoupling Click Recording from the Redirect Path
In Article 06, the open-link handler counted clicks right inside the redirect path: every open had to wait for a DynamoDB write to finish before returning 301. That works, but it forces the hottest path of the system to carry the extra write, and any future analytics work piles up at exactly that spot. This article splits it apart: opening a link only publishes an event, while counting and analysis are left to a different component. This is the first brick of the event-driven architecture.
Goal
Build a custom event bus on EventBridge, have the resolve handler publish a link.clicked event instead of counting directly, attach a consumer that receives the event via an event pattern, then open a real link to watch the event travel from resolve through the bus to the consumer. The consumer in this article only logs, to prove the flow; the next article turns it into a real counter. EventBridge cost for a small volume of events is negligible.
Why split it apart
Mixing counting into the redirect path creates an unnecessary pair of constraints. The open-link path should be as fast and simple as possible, since it runs the most; but analytics wants to do more things over time (count by day, by source, detect anomalies). If both live in one handler, every time you add analytics work you slow down the redirect path, and a bug in the counting part can break opening links entirely.
The event-driven architecture cuts that thread. Resolve publishes an event then returns 301 immediately; whoever cares about clicks subscribes to the event and processes it independently. Adding a new kind of processing later is just adding a consumer, without touching resolve. The hot path gets lighter, and the parts are isolated from each other in both performance and failure.
What an event looks like
EventBridge describes an event as "a change in an environment", represented as JSON with the same top-level fields. An event we publish looks like:
{
"source": "urlshortener",
"detail-type": "link.clicked",
"detail": { "code": "s2fYqYI", "at": "2026-05-25T16:56:50.669Z" }
}
source says who published it, detail-type says the event type, and detail holds the event's own data. EventBridge adds fields like id, time, account, region on receipt. The three fields we fill are enough for the consumer to know "which link was just opened, and when".
Custom bus instead of the default bus
Every account has a built-in default event bus, where AWS services send their events. We create a separate custom bus for the application to keep our events out of that noisy stream, making it easier to scope permissions and set rules:
EventBus:
Type: AWS::Events::EventBus
Properties:
Name: url-shortener-events
Resolve publishes the event
The open-link handler now looks up the link, publishes the event, then returns 301. The atomic counter from Article 06 is removed (it moves to the consumer in the next article):
const eb = new EventBridgeClient({});
const BUS = process.env.EVENT_BUS ?? "default";
// ...after GetItem finds the link...
await eb.send(
new PutEventsCommand({
Entries: [
{
EventBusName: BUS,
Source: "urlshortener",
DetailType: "link.clicked",
Detail: JSON.stringify({ code, at: new Date().toISOString() }),
},
],
})
);
return { statusCode: 301, headers: { location: res.Item.target as string }, body: "" };
Resolve's permissions change accordingly: it no longer writes to the table, so it's lowered from read-write to read-only, and gains permission to publish events onto the right bus:
Environment:
Variables:
TABLE_NAME: !Ref Table
EVENT_BUS: !Ref EventBus
Policies:
- DynamoDBReadPolicy:
TableName: !Ref Table
- EventBridgePutEventsPolicy:
EventBusName: !Ref EventBus
Consumer subscribes via an event pattern
A consumer doesn't receive everything on the bus, it declares an event pattern to filter. The docs describe it: "An event pattern defines the data EventBridge uses to determine whether to send the event to the target. If the event pattern matches the event, EventBridge sends the event to the target." A pattern has the same structure as the event it matches.
Our consumer subscribes to receive exactly source: urlshortener and detail-type: link.clicked. In SAM, a function declaring an EventBridgeRule source event automatically creates the rule, target, and permission:
ClickLoggerFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/click-logger.handler
Events:
Clicked:
Type: EventBridgeRule
Properties:
EventBusName: !Ref EventBus
Pattern:
source: [urlshortener]
detail-type: [link.clicked]
The handler in this article just logs so we can see the event arrive in the right shape:
export const handler = async (
event: EventBridgeEvent<"link.clicked", { code: string; at: string }>
): Promise<void> => {
console.log("CLICK EVENT", JSON.stringify({
source: event.source, detailType: event["detail-type"], detail: event.detail,
}));
};
GET /{code} EventBridge: url-shortener-events (bus)
│ │
ResolveLinkFunction ┌──────┴───────┐
─ GetItem (target) │ rule: │ pattern match?
─ PutEvents ──────────────────────▶ │ source=... │──┬── match ──▶ ClickLoggerFunction
─ return 301 now │ detail-type= │ │ (log the event)
└──────────────┘ └── no ──────▶ (ignore)
Open a real link, watch the event flow
Create a link then open it three times, each open publishing an event:
$ for i in 1 2 3; do curl -s -o /dev/null -w "GET /$CODE -> %{http_code}\n" "$API/$CODE"; done
GET /s2fYqYI -> 301
GET /s2fYqYI -> 301
GET /s2fYqYI -> 301
Read the consumer's log, and see exactly three events that traveled through the bus and arrived:
$ aws logs tail "/aws/lambda/$CLICK_LOGGER" --since 2m | grep 'CLICK EVENT'
... CLICK EVENT {"source":"urlshortener","detailType":"link.clicked","detail":{"code":"s2fYqYI","at":"2026-05-25T16:56:50.669Z"}}
... CLICK EVENT {"source":"urlshortener","detailType":"link.clicked","detail":{"code":"s2fYqYI","at":"2026-05-25T16:56:51.290Z"}}
... CLICK EVENT {"source":"urlshortener","detailType":"link.clicked","detail":{"code":"s2fYqYI","at":"2026-05-25T16:56:51.609Z"}}
Resolve and the consumer are now two separate components, joined through the bus. Resolve knows nothing about who is listening; it just publishes. The consumer knows nothing about resolve; it just declares the pattern it cares about. Adding a second consumer later (say, a component that detects spammed links) will just be adding a rule on the same bus, without modifying resolve.
🧹 Cleanup
Delete the demo user and link, keep the bus and consumer for the next article:
aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"LINK#$CODE\"},\"SK\":{\"S\":\"META\"}}"
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username ev@example.com
Wrap-up
Click recording is now decoupled from the redirect path. Resolve publishes a link.clicked event onto the custom bus then returns 301 immediately, while the consumer subscribes via an event pattern and processes independently. Neither side knows about the other, only about the event. The hot path is lighter, and analytics has its own place to grow.
But the current consumer only logs, and it's still naive about two problems of real asynchronous processing. The same event can be delivered more than once, so if you just increment the counter on every receipt you'll double-count. And when processing fails, the event will be retried, then where does it go if it still fails? The next article turns this consumer into a real aggregator: counting clicks into DynamoDB, making it idempotent so duplicate receipts don't count wrong, and attaching a dead-letter queue so failed events don't vanish.