Design an Idempotent Event Processor
At-least-once delivery means the same webhook arrives twice. A map from event id to stored result, a record that only lands on the success path, and a ttl so the map does not grow forever.
- Pattern
- Map event id -> stored result
- Time
- O(1) per event, O(expired) per prune
- Space
- O(live ids)
Every webhook sender you will be asked about delivers at least once. An ack gets dropped, the sender retries, and the same charge.succeeded lands twice. Idempotency is not a feature bolted on later. It is the shape of the receiver.
The recipe
Say before you type: "Keyed on event id. Look it up, run the handler, and record the result only if the handler returned."
Three fields carry the whole problem.
private entries = new Map<string, { result: unknown; at: number }>();
private counts = { applied: 0, duplicates: 0, failed: 0 };
private ttlMs: number;
Presence, not truthiness
Use has(), not get().
if (this.entries.has(event.id)) {
return { status: "duplicate", result: this.entries.get(event.id).result };
}
A handler is allowed to return undefined, null, or 0. If your dedupe check is if (this.results.get(id)) you have written a processor that re-runs every event whose handler returned nothing, which is most of them. Say the difference out loud: you are asking have I seen this id, not do I have something interesting for this id.
And key on the id alone. A redelivery is a fresh serialization of the same event, so a retried charge.succeeded can carry a data blob that differs by a timestamp or a field the sender added last week. Fold type or data into the key and every one of those redeliveries reads as new. This is the version of the bug that survives code review, because the key looks more careful than the right one.
The record goes on the success path only
let result: unknown;
try {
result = this.handler(event);
} catch (error) {
return { status: "failed", error };
}
this.entries.set(event.id, { result, at: nowMs });
return { status: "applied", result };
Announce that you are not using finally. A finally records the failure too, so the id is marked processed, the sender's retry comes back duplicate, and a real event is gone forever with no error anywhere. That is the bug that pages someone at 3am and takes two days to find, because the only symptom is a customer who was never charged.
Same reasoning, one line earlier: record after the handler returns, never before. Marking the id first and filling in the result later is the same bug wearing a different hat.
Return the thrown value unchanged. Do not stringify it, do not reach for .message. Handlers throw strings, objects, and things that came back from a driver.
The ttl is half open
An entry recorded at t is live while nowMs - t < ttlMs.
for (const [id, entry] of this.entries) {
if (nowMs - entry.at >= this.ttlMs) this.entries.delete(id);
}
Pick >= on the first line and say why. At exactly ttlMs the entry is gone, so a replay at exactly ttlMs runs the handler again. That is intended. Past the window there is nothing left to be idempotent about, and the honest sentence is "my dedupe guarantee is scoped to the window, and the window has to be wider than the sender's retry schedule."
Two details that are easy to lose:
- Default
ttlMstoInfinity, not0.ttlMs || 0prunes every entry the instant it is written and quietly turns dedupe off. - A duplicate is a read. It does not refresh
at. Refreshing turns a ttl into a sliding window, and a hot id then lives forever, which is the exact leak you added the ttl to close.
The counters count attempts
tracked is live state, so it goes up and down with pruning. applied, duplicates, and failed count calls to process and only ever go up.
stats() {
return {
tracked: this.entries.size,
applied: this.counts.applied,
duplicates: this.counts.duplicates,
failed: this.counts.failed,
};
}
Deriving applied from entries.size looks right until the first prune, and then your dashboard reports that traffic went down. Four replays of one id are four duplicates, not one.
What the interviewer is listening for
They already know the map. What they are checking is whether you know that failure is not delivery, and whether you can name the cost of the ttl instead of pretending it has none. Both are one sentence each. Have them ready.