Skip to content
Chapter 70Lesson 4

Dedup the rapid duplicates

A short-window dedup mechanic that stops the dispatcher from sending the same notification to the same person twice during rapid bursts and races.

A user clicks “Resend invitation,” nothing visibly happens for half a second, so they click it again, and again: five clicks in two seconds. Or a payment webhook times out mid-process and the provider redelivers it three times. Or two admins in the same Slack thread both hit “Demote to member” on the same person at the same moment. Each of these asks the dispatcher to fire the same notification more than once in a very short span, and right now it would. Five clicks become five emails and five inbox rows. The user is annoyed, their inbox is cluttered, and every redundant email chips away at your sender reputation, which the mailbox providers track.

You have already built almost everything needed to stop this. The dispatcher resolves channels, respects each recipient’s preferences, and fans out to email and the inbox. But it still fires once per call, because it has no memory of what it just sent. Back when you defined the dispatcher’s contract, you declared a result shape with three counters: { sent, deduped, suppressedByPrefs }. Two of them are now real: the channel fan-out fills sent, and the preference resolution fills suppressedByPrefs. The third, deduped, has sat at zero through two lessons, a promise the dispatcher makes but never keeps. This lesson makes it real with one small mechanic: before firing for a recipient, ask whether you already sent this exact thing to this exact person in the last minute. Three moving parts carry the whole idea: a window (how long counts as “recently”), a key (what counts as “this exact thing”), and a place (where you record what you fired). That is the last brick in the dispatcher.

The mechanic is a short-term memory. You keep a dedicated table, call it notification_dedup, and every time the dispatcher fires for a recipient, it records a row there: this event, this key, this person, fired at this instant. Before it fires the next time, it checks that table for a matching row stamped within the last 60 seconds. If one exists, the dispatcher skips the send and counts a dedup . If none exists, it fires and writes a fresh row. That is the entire loop: check, then either skip-and-count or send-and-record.

Why 60 seconds? Because that window comfortably covers the tight bursts and nothing wider. Rage-clicks land sub-second. A double form submit, two near-simultaneous admin actions, and a fast in-process retry all collapse into a span far shorter than a minute. Push the window out to ten minutes and you start dropping notifications the user genuinely wants: someone who re-invites a colleague after a real conversation half an hour later should get a fresh email, not silence. Pull it in to five seconds and slower bursts leak through. Sixty seconds handles the overwhelming majority of cases without swallowing legitimate repeats, so it is the default a new event type gets unless you have a reason to change it.

It is tempting to reach for a bigger number so the window also catches provider webhook retries, but that is the wrong instinct, and seeing why sharpens the whole design. Real provider retries are spaced much wider than a minute. Stripe’s first retry is around five minutes out, then thirty minutes, then two hours, escalating over three days. A 60-second window will never span those, and it is not supposed to. Widely-spaced redeliveries are caught one layer earlier, at the webhook handler, by the processed_events ledger you built for idempotency. A replayed event produces no second state change there, so it fires no second notification from the database at all. The dispatcher’s window owns a different problem: tight bursts and concurrent firings that the handler never sees. We will come back to how these two layers compose. For now, the takeaway is that 60 seconds is sized for clicks and races, not for retries.

The window is not always 60 seconds, though, and the right place to vary it is the registry, not a global constant. High-frequency event types like comments or mentions (if and when they ship) want a longer window, because their bursts are noisier and slower. The registry already owns per-event configuration: channels, template, and preference category. The dedup window is just one more field on the entry. Here is the dedup block on the invitation event:

src/lib/notifications/registry.ts
export const notifiableEvents = {
'org.invitation.sent': {
channels: ['email', 'inbox'],
template: invitationEmail,
dedup: { windowSeconds: 60, keyBy: ['subjectId'] },
preferenceCategory: 'team',
description: 'Someone was invited to your organization',
},
// …other events
} as const satisfies Record<string, NotifiableEvent>;

One quiet thing to watch before we move on: never size the window to match the cadence of whatever retries it must absorb. If a cron job retries every 60 seconds and your window is also 60 seconds, a retry lands right on the boundary, sometimes inside the window and sometimes a hair outside depending on millisecond timing. The result is nondeterministic duplicates that are hard to debug. Always pick a window comfortably larger than the longest interval it needs to swallow, never equal to it.

The key decides what counts as the same thing

Section titled “The key decides what counts as the same thing”

The window tells you how recently. The key tells you what counts as the same notification, and it is the decision people get wrong most often. Get the key wrong and the window is irrelevant: either nothing ever dedupes, or things that should stay separate get collapsed.

The dedup row is identified by a composite key of three parts: (eventType, dedupKey, recipientUserId). Each one is there for a reason:

  • eventType is the obvious first cut. A role change and an invitation are never duplicates of each other, even for the same person at the same instant.
  • dedupKey is the per-event discriminator, and it is the interesting part. The registry’s dedup.keyBy is an array of payload or subject field names, and the dispatcher reads those fields to build the key string. Which fields go in keyBy encodes the business meaning of “duplicate” for that event, and that meaning differs from event to event, which is exactly why it lives in the registry instead of being hardcoded.
  • recipientUserId makes dedup per person. The same event reaching two different people is not a duplicate, because each recipient has their own independent window. A role change might notify the demoted member and, separately, surface to an admin; those two don’t collide, because the recipient is part of the key.

Two examples make the keyBy choice concrete. For 'org.invitation.sent', the key is just ['subjectId'], the invitation’s id. Resending the same invitation five times dedupes to one, because all five share a subject id. For 'org.member.role_changed', the key is ['subjectId', 'newRole'], the member’s id and the role they were changed to. A frantic demote-then-promote-then-demote within a minute produces one notification per distinct transition, not one notification total, because each transition has a different newRole. The key captures the uniqueness the business cares about, no more and no less.

const notifiableEvents = {
'org.invitation.sent': {
// …channels, template, preferenceCategory, description
dedup: { windowSeconds: 60, keyBy: ['subjectId'] },
},
'org.member.role_changed': {
// …channels, template, preferenceCategory, description
dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] },
},
} as const satisfies Record<string, NotifiableEvent>;

The invitation’s keyBy: ['subjectId']. One field suffices, because the invitation id alone fully identifies the thing. Two sends of the same invitation share a subject id, so they dedupe; two different invitations carry different ids, so they stay separate.

const notifiableEvents = {
'org.invitation.sent': {
// …channels, template, preferenceCategory, description
dedup: { windowSeconds: 60, keyBy: ['subjectId'] },
},
'org.member.role_changed': {
// …channels, template, preferenceCategory, description
dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] },
},
} as const satisfies Record<string, NotifiableEvent>;

The role change’s keyBy: ['subjectId', 'newRole']. The member id alone would collapse every role change for that member into one. Adding newRole makes each distinct transition its own notification, so a demote and the following promote are not duplicates of each other.

const notifiableEvents = {
'org.invitation.sent': {
// …channels, template, preferenceCategory, description
dedup: { windowSeconds: 60, keyBy: ['subjectId'] },
},
'org.member.role_changed': {
// …channels, template, preferenceCategory, description
dedup: { windowSeconds: 60, keyBy: ['subjectId', 'newRole'] },
},
} as const satisfies Record<string, NotifiableEvent>;

Both keys are missing a third, invisible dimension: recipientUserId. The dispatcher adds it at check time, not in the registry, because dedup is always per-recipient. The registry decides what makes an event the same; the dispatcher scopes that sameness to a single person.

1 / 1

This is where dedup usually breaks, and the two failure modes are mirror images of each other.

A key that is too narrow folds in something that changes on every firing: a timestamp, a request id, a random nonce. Now every event is unique, no two rows ever match, and dedup silently does nothing. The system looks fine; it just never dedupes. You spot it when the dedup rate sits flat at zero even though you know bursts are happening.

A key that is too broad does the opposite. A key of only eventType collapses unrelated events that happen to share a type: two different invitations to two different colleagues dedupe into one, and one of them silently never arrives. You spot it when legitimate, distinct notifications go missing. The fix is almost always the same: put the subject in the key, so different subjects stay different.

Closely related is what happens on a burst: first one wins. On five rapid clicks, the first firing writes the inbox row and sends the email; the next four match that row and are dropped. The payload the user sees is the first event’s payload. Usually the bursting events are identical, so this doesn’t matter. But if they differ and the wrong one wins, that is a signal that your dedup key is too broad: it should have distinguished those events into separate notifications in the first place. Read it as a diagnostic, and add the discriminating field to the key rather than widening the window.

Try this to build your intuition for the key. An app fires a 'comment.created' event and wants to drop rapid duplicate notifications when the same comment is delivered twice, while still notifying on genuinely new comments.

Which dedup.keyBy drops the repeat delivery of the same comment yet still lets a genuinely new comment notify?

['createdAt']
['eventType']
['subjectId']
['subjectId', 'createdAt']

With the window and the key settled, the only question left is where the check goes, and the answer is one precise slot in the per-recipient loop you already built. The dispatcher’s flow, start to finish, is short: look the event up in the registry, batch-read every recipient’s preferences, then for each recipient resolve their channels, run the dedup check, fan out to the channels, and record the dedup row. Two of those orderings are decisions worth defending.

Preferences come before dedup. A recipient who has muted this category resolves to an empty channel list: there is nothing to send them, so there is nothing to dedup. If you checked dedup first and wrote a row for them anyway, you would break two things at once. The deduped count would fill with phantom skips for people who never received anything, and a later, genuinely-wanted notification could match that phantom row and get wrongly dropped. So resolve channels first; if the list is empty, skip the recipient entirely before the dedup table is ever touched.

Dedup comes before fan-out. The entire point of the check is to not send. So you check, and on a hit you skip the inner channel loop and increment result.deduped. The dedup row insert goes after a successful fan-out: it records that this event, this key, and this person were actually delivered, which is precisely what the next check is looking for.

This is also where the contract closes. When the dispatcher’s contract was first declared, it promised a DispatchResult with three counters. The channel fan-out filled sent. The preference resolution filled suppressedByPrefs. This result.deduped++ fills the third and last one. After this lesson, every counter the dispatcher promised is real, and the contract is whole.

Walk the burst through the machine one click at a time:

Click 1: the dedup check finds no matching row, so it fires. Both channels fan out, and a row is written stamped T0. First one wins.
Click 2, 0.3s later: a matching row already exists inside the 60-second window, so the fan-out is skipped and result.deduped ticks to 1.
Clicks 3 and 4: same story. Each matches the same row and is dropped, so the burst is absorbed and deduped climbs to 3.
Click 5: skipped like the rest. One delivery, four dedups, and the table still holds exactly the one row that absorbed them all.

The result the contract promised, now real: one email queued, one inbox row, four duplicates dropped, { sent: 1, deduped: 4, suppressedByPrefs: 0 }. The deduped counter L1 declared and L2–L3 left at zero is finally filled.

The sequence shows the burst over time. The next figure pins the placement statically: exactly where in the per-recipient loop each check sits, and which branch leads where.

%%{init: {'themeCSS': '.nodeLabel, .nodeLabel * { font-size: 15px !important; } .edgeLabel, .edgeLabel * { font-size: 14px !important; }'} }%%
flowchart LR
  resolve["resolveChannels"]
  empty{"channels<br/>empty?"}
  dedup["dedup check"]
  dup{"duplicate?"}
  fanout["fan out to<br/>channels"]

  skip(["skip recipient"])
  counted(["result.deduped++"])
  record(["insert<br/>dedup row"])

  resolve --> empty
  empty -- yes --> skip
  empty -- no --> dedup
  dedup --> dup
  dup -- yes --> counted
  dup -- no --> fanout
  fanout --> record

  class resolve,dedup,fanout step
  class empty,dup gate
  class skip skipped
  class counted deduped
  class record sent
  classDef step fill:#1f2937,stroke:#94a3b8,color:#f8fafc
  classDef gate fill:#dbeafe,stroke:#1d4ed8,color:#111,stroke-width:2px
  classDef skipped fill:#fef3c7,stroke:#b45309,color:#111,stroke-width:2px
  classDef deduped fill:#e9d5ff,stroke:#7e22ce,color:#111,stroke-width:2px
  classDef sent fill:#bbf7d0,stroke:#15803d,color:#111,stroke-width:2px
Inside the per-recipient loop: preferences gate first, then dedup, then the send, and the dedup row is written only after a real delivery.

In code, the change is genuinely small: a check and a counter bump dropped into the loop you already own. Here is the per-recipient loop with the dedup lines inserted:

for (const userId of recipientUserIds) {
const channels = resolveChannels(event, prefsByUser.get(userId));
result.suppressedByPrefs += event.channels.length - channels.length;
if (channels.length === 0) continue;
if (await isDuplicate({ event, userId, payload })) {
result.deduped++;
continue;
}
for (const channel of channels) {
await runChannel(channel, { recipient: { userId }, event, payload, rendered });
}
result.sent++;
await recordDedup({ event, userId, payload });
}

Resolving this recipient’s channels and tallying what their preferences suppressed is straight from last lesson. The empty-skip guard on line 4 is the new companion to the dedup ordering: skipping the recipient when nothing is left is what keeps dedup rows from being written for people who receive nothing.

for (const userId of recipientUserIds) {
const channels = resolveChannels(event, prefsByUser.get(userId));
result.suppressedByPrefs += event.channels.length - channels.length;
if (channels.length === 0) continue;
if (await isDuplicate({ event, userId, payload })) {
result.deduped++;
continue;
}
for (const channel of channels) {
await runChannel(channel, { recipient: { userId }, event, payload, rendered });
}
result.sent++;
await recordDedup({ event, userId, payload });
}

The new dedup check. isDuplicate returns a plain boolean; on a hit, increment result.deduped and continue to the next recipient without sending. This is the line that finally fills the third counter.

for (const userId of recipientUserIds) {
const channels = resolveChannels(event, prefsByUser.get(userId));
result.suppressedByPrefs += event.channels.length - channels.length;
if (channels.length === 0) continue;
if (await isDuplicate({ event, userId, payload })) {
result.deduped++;
continue;
}
for (const channel of channels) {
await runChannel(channel, { recipient: { userId }, event, payload, rendered });
}
result.sent++;
await recordDedup({ event, userId, payload });
}

The fan-out, unchanged from last lesson: loop the resolved channels, run each through the runChannel wrapper, then count one sent.

for (const userId of recipientUserIds) {
const channels = resolveChannels(event, prefsByUser.get(userId));
result.suppressedByPrefs += event.channels.length - channels.length;
if (channels.length === 0) continue;
if (await isDuplicate({ event, userId, payload })) {
result.deduped++;
continue;
}
for (const channel of channels) {
await runChannel(channel, { recipient: { userId }, event, payload, rendered });
}
result.sent++;
await recordDedup({ event, userId, payload });
}

Record the dedup row after a successful fan-out, so the next firing inside the window can find it. Recording before sending would leave rows for sends that might still fail.

1 / 1

The two helpers, isDuplicate and recordDedup, are thin wrappers in lib/notifications/. isDuplicate reads the registry entry to learn the window and keyBy, builds the key from the payload, and runs a single existence query against notification_dedup; recordDedup inserts one row. Each takes an options object (the event, the recipient, the payload) so it stays under the two-positional-argument line, and both start with import 'server-only'. Note that isDuplicate returns a bare boolean, not a Result<T>. This is internal bookkeeping with one obvious answer, not a user-facing operation that can fail in meaningful ways, so it follows the same plain-result divergence the dispatcher itself does.

Order matters here, so put the per-recipient steps in the order the dispatcher runs them:

Order the steps the dispatcher runs for a single recipient. Drag the items into the correct order, then press Check.

resolveChannels — read this recipient’s enabled channels
Skip the recipient if no channels are enabled
Dedup check — has this exact thing fired for them in the window?
Fan out to the enabled channels
Insert the dedup row recording the delivery

The mechanic leans entirely on one small table and one index. Both follow the same conventions as the tables you have already built in this chapter: a UUIDv7 primary key, snake-case columns mapped from the client, an explicit foreign key, and an explicitly-named index. There is nothing exotic here, only one detail worth flagging.

That detail is the missing orgId column. Dedup is keyed on the recipient and the event identity, not on the tenant, the same user-scoped reasoning the preferences table uses. The “lead composite indexes with the tenant column” rule applies to tenant-scoped data, the rows users query and admins audit. This table is internal bookkeeping that the dispatcher reads and a cleanup job prunes; no user ever queries it by org. So it stays user-scoped, and the index leads with the columns the check actually filters on.

export const notificationDedup = pgTable(
'notification_dedup',
{
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
eventType: text().notNull(),
dedupKey: text().notNull(),
recipientUserId: text()
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
firedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('idx_notification_dedup_lookup').on(
t.eventType,
t.dedupKey,
t.recipientUserId,
t.firedAt.desc(),
),
],
);

The three key columns plus the recipientUserId foreign key, together the composite key the check filters on. recipientUserId is text, not uuid, to match Better Auth’s user.id (a FK always matches the type of the column it references), with a cascade delete so a removed user’s bookkeeping rows go with them.

export const notificationDedup = pgTable(
'notification_dedup',
{
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
eventType: text().notNull(),
dedupKey: text().notNull(),
recipientUserId: text()
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
firedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('idx_notification_dedup_lookup').on(
t.eventType,
t.dedupKey,
t.recipientUserId,
t.firedAt.desc(),
),
],
);

firedAt is a timestamptz defaulting to now(), the stamp the window compares against. This is the column the “within the last 60 seconds” predicate ranges over.

export const notificationDedup = pgTable(
'notification_dedup',
{
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
eventType: text().notNull(),
dedupKey: text().notNull(),
recipientUserId: text()
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
firedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('idx_notification_dedup_lookup').on(
t.eventType,
t.dedupKey,
t.recipientUserId,
t.firedAt.desc(),
),
],
);

The index. Its column order matches the check exactly: equality on the three key columns, then firedAt descending for the range. The explicit name follows the index-naming convention. Shipping the index with the table turns the dedup check into one fast indexed read instead of a scan of a table that grows on every send.

1 / 1

The index is not optional. The dedup check runs on every dispatch, and the table grows by a row on every delivery. Without an index that matches the check’s filter, each check degrades into a linear scan of an ever-larger table, which would make the dispatcher slower the more it works. With the index, the check is the existence query below, which Postgres answers from the index in roughly constant time:

select 1 from notification_dedup
where event_type = $1 and dedup_key = $2 and recipient_user_id = $3
and fired_at > now() - interval '60 seconds'
limit 1;

Existence is all that matters: limit 1, no columns to read back, just whether a matching recent row exists. In the Drizzle helper this is the one place a sql\`tagged-template fragment appears, for thefired_at > now() - interval` range with the window value parameterized in; the rest of the query is ordinary Drizzle operators.

One thing the table needs that this chapter does not build is pruning. Left alone, notification_dedup grows one row per delivered notification, forever. The window only ever looks back 60 seconds (or whatever the longest configured window is), so any row older than that plus a small buffer is dead weight. A nightly background job, the kind you reach for with a tool like Trigger.dev, runs a single indexed delete where fired_at < now() - (longest window + buffer) and keeps the table small. Two rules apply. It runs on a schedule, not in the request path, because pruning inline would bolt cleanup latency onto a user’s action. And it is genuinely deferred here: you name it and size the delete, but you do not build it in this lesson.

Now see the window do its job. The exercise below seeds notification_dedup with two rows: one for the invitation inv_123 to user_a fired about five seconds ago, and one for a different key fired about ten minutes ago. Finish the existence check so it returns the recent matching row and excludes the ten-minute-old one. The time predicate does the work.

Finish the dedup check: return whether a matching row exists for ('org.invitation.sent', 'inv_123', 'user_a') fired within the last 60 seconds. The recent row should survive; the ten-minute-old row must fall outside the window.

View schema & seed rows
Schema (Drizzle)
export const notificationDedup = pgTable('notification_dedup', {
  id: text('id').primaryKey(),
  eventType: text('event_type').notNull(),
  dedupKey: text('dedup_key').notNull(),
  recipientUserId: text('recipient_user_id').notNull(),
  firedAt: timestamp('fired_at', { withTimezone: true }).notNull(),
}, (t) => [
  index('idx_notification_dedup_lookup').on(
    t.eventType, t.dedupKey, t.recipientUserId, t.firedAt.desc(),
  ),
]);
Seed rows (SQL)
INSERT INTO notification_dedup (id, event_type, dedup_key, recipient_user_id, fired_at) VALUES
  ('d1', 'org.invitation.sent', 'inv_123', 'user_a', now() - interval '5 seconds'),
  ('d2', 'org.invitation.sent', 'inv_999', 'user_a', now() - interval '10 minutes');

There is a neighbouring technique that is easy to confuse with dedup, and keeping them straight stops you from over-building. Dedup drops the duplicate outright, which is everything above. Coalesce does something different: it collapses a burst of distinct events into one summary. “Jane commented 5 times on Invoice #42” is coalesce, five real and different comments rolled into a single notification so the inbox isn’t flooded. That is a different shape with a different data model (you collect the events into a pending bucket and flush it on a timer or a count threshold) and a different feel for the user.

The decision rule is about what the repeats are. Reach for dedup when the repeats are the same event the user should see exactly once: a resent invitation, a retried webhook, a double-clicked button. Reach for coalesce when the repeats are distinct but noisy, like a flurry of separate comments or a burst of individual mentions, that the user would rather see summarized than one-by-one. Dedup says “you already know this.” Coalesce says “here’s the gist of a lot of small things.”

This dispatcher ships dedup only. Coalesce earns its weight the day noisy event types like comments or mentions arrive and the inbox starts to feel like spam, and not a moment before. It is the canonical next step, the mechanism is a sentence (bucket the events, flush on a timer), and building it now would be solving a problem you don’t yet have. Walk the decision once so the order of the questions sticks:

Drop, send each, or summarize?

Webhook idempotency and dispatcher dedup are different layers

Section titled “Webhook idempotency and dispatcher dedup are different layers”

It is natural to wonder whether the webhook idempotency you already built makes this dispatcher dedup redundant. It does not, and the cleanest way to hold both in your head is to see that they guard different things at different boundaries.

Idempotency at the webhook handler stops state churn. When a provider redelivers the same event, the same event.id replayed, the processed_events ledger recognizes it as already-handled and produces no second state transition. No second row is updated, no second status flips, and so no second event fires from the database at all. The ledger is keyed on the provider and the event id, and it lives at the edge.

But the ledger can only catch re-deliveries of the same event id. Two things slip past it. The same logical event might arrive under two different event ids, which providers occasionally do. Or the same real-world action might arrive by two paths at once: a webhook and a direct user action firing the same dispatch(). In both cases the ledger sees two distinct events and lets both through. What stops the notification from doubling is the dispatcher’s dedup window, which checks whether this exact notification already went to this exact person. That is a different layer, watching a different kind of duplicate.

So the two compose, and a thoughtful engineer reaches for both: webhook idempotency prevents redundant state writes at the edge, and dispatcher dedup prevents redundant notifications at the seam. Neither makes the other unnecessary.

Two layers, two duplicates: the ledger at the edge guards state, the dedup window at the dispatcher guards notifications.

One last habit before the dispatcher is done: watch the counter you just filled. DispatchResult already reports { sent, deduped, suppressedByPrefs } on every call, and a structured logger captures those counts per dispatch, one line you can query later.

logger.info({ seam: 'notifications.dispatch', ...result });

The signal is in the shape of the dedup rate, not its presence. A steady, low dedup rate is the system working exactly as designed: the occasional double-click absorbed, a stray retry caught. A sudden spike is the interesting one, because it means a call site is firing duplicates that should not exist, such as a re-render firing the same action twice, or a loop calling dispatch once per row instead of once per batch. Seeing that dedup is happening and concluding the system is healthy misses the point, because the bug hides in the change, not the floor. So alert on the change in the rate. The dashboard that surfaces this belongs to the observability work later in the course; the point here is that the counter this lesson filled is also a health metric.

And with that, the dispatcher is complete. You named the seam and its contract, built the two channels, resolved preferences once in one place, and now dedup the rapid duplicates. Every counter the contract declared is real, and the seam is done across four lessons. Next, you wire this finished dispatch() into three real call sites and watch a single event fan out across email and the inbox for the first time.

The two ideas under this lesson, dedup and idempotency, are the same insight at two layers, and the distributed-systems literature is where that insight is sharpest.