Why your notification retry should never send the same push twice
Last reviewed 26 October 2026 · 4 min read
A retry that re-sends the same push is not a retry, it is a second notification. The fix is to give every send an id derived from its content, so the second attempt is recognised as the first one and dropped, at the sender, at the provider, and on the device.
The failure mode
Your backend sends a push. The HTTP call to APNs or FCM times out after the provider has already accepted the message. Your job queue sees a failure and retries. The user gets two banners for one invoice.
It happens more than you'd think, because every layer retries independently: your queue, your HTTP client, the provider's own delivery, and on iOS the system can deliver a notification again after a device restore or an app reinstall. Teams usually discover it from a support ticket with a screenshot of two identical banners, then try to fix it in the client by hiding the second one. That hides the symptom in one app and leaves it in the web push and the in-app feed.
The root cause is that the retry carries no identity. Two sends with the same payload are, to every system in the chain, two different messages.
The fix: an id derived from content
Give each notification an id that is a function of what it is, not of when you sent it. Hash the notification type, the recipient, and the fields that make this notification this one. Do not include a timestamp, a random nonce, or the attempt number.
notification-id.ts
import { createHash } from "node:crypto";
export function notificationId(input: {
type: string; // "invoice.paid"
recipient: string; // your user id, never an email
key: Record<string, string | number>; // what makes this one unique
}): string {
const canonical = JSON.stringify({
t: input.type,
r: input.recipient,
k: Object.keys(input.key).sort().reduce((acc, k) => {
acc[k] = input.key[k];
return acc;
}, {} as Record<string, string | number>),
});
return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
}
// invoice.paid for user u_8831, invoice inv_2201 -> always the same id
notificationId({ type: "invoice.paid", recipient: "u_8831", key: { invoice: "inv_2201" } });The key is the important design decision. For "invoice paid" it is the invoice id. For "new comment" it is the comment id. For "your export is ready" it is the export job id. If you cannot name the key, you have not decided what one notification is, and no amount of retry logic will fix that.
Then use that id in three places.
1. At the sender: a sent-set
Before calling the provider, insert the id into a store with a unique constraint. If the insert fails, the send already happened or is in flight; stop.
sends.sql
create table sends (
id text primary key, -- the content-derived id
recipient text not null,
type text not null,
created_at timestamptz not null default now()
);
-- in the send path, inside the same transaction that enqueues the provider call
insert into sends (id, recipient, type) values ($1, $2, $3)
on conflict (id) do nothing
returning id;
-- no row returned = duplicate, do not sendThis makes the whole send idempotent regardless of what the queue does. A job can run five times; only the first inserts a row.
2. At the provider: collapse ids
APNs has apns-collapse-id; FCM has collapse_key on Android and the same apns-collapse-id header for iOS. Set it to your notification id. If the first attempt did land and the second slips through, the device replaces the first banner instead of stacking a second one.
collapse-ids.ts
// APNs HTTP/2 headers
headers["apns-collapse-id"] = id; // max 64 bytes
headers["apns-id"] = uuidFrom(id); // a UUID v5 of the id, so provider logs line up with yours
// FCM v1 message (firebase-admin)
message.android = { collapseKey: id };
message.apns = { headers: { "apns-collapse-id": id } };apns-id is worth setting too: it is the id APNs echoes back on errors and in delivery logs, and if you derive it deterministically from your own id you can join the two sides without a lookup table.
3. On the device: a seen-set
Clients should still keep a small set of ids they have displayed, because the collapse id does not cover every path (a notification service extension that re-posts, a web push that arrives after a reconnect). Put the id in the payload and check it before showing anything.
service-worker.ts
// service worker, web push
self.addEventListener("push", (event) => {
const data = event.data?.json();
event.waitUntil((async () => {
const seen = await caches.open("seen-notifications");
if (await seen.match(data.id)) return; // already shown
await seen.put(data.id, new Response("1"));
await self.registration.showNotification(data.title, { body: data.body, tag: data.id });
})());
});The tag option on showNotification is the browser's own collapse key; using the same id there costs nothing.
What this does not fix
A retry that produces a different payload is not a duplicate, it is a new notification. If your "invoice paid" send is retried after the amount was corrected, the id changes and the user gets two, correctly. That is a feature; the alternative is silently dropping the corrected one. Decide whether a field belongs in key or in the display body, and keep display-only fields out of the hash.
And none of this helps if the id is generated inside the retry. Compute it once, where the event happens, and pass it down.
How NotiFixed does it
NotiFixed takes the id from you rather than deriving it. Send it as dedupeKey on POST /v1/notifications, and a second call with the same key is ignored: a retry after a timeout still means one item in the feed and one push per device, and a deduplicated retry does not count against your plan. Without a dedupeKey, two identical calls are two notifications, because NotiFixed cannot tell your retry from a colleague sending the same thing on purpose.
The notificationId function above is a good way to make the key. Compute it where the event happens and send the same value on every attempt:
POST https://app.notifixed.com/v1/notifications
POST https://app.notifixed.com/v1/notifications
Authorization: Bearer $NOTIFIXED_API_KEY
Content-Type: application/json
{
"type": "invoice.paid",
"recipients": ["u_8831"],
"payload": {
"invoice_number": "inv_2201",
"amount": "1 240,00 zł",
"customer_name": "Globex"
},
"dedupeKey": "52f364d9288c640b306b566e509104bd"
}NotiFixed's permanent record keeps what was sent, to whom and in which language for a year on every plan, so when a customer asks "did you send this twice?" there is something to check.
Further reading
- NotiFixed docs: sending, and
dedupeKey - NotiFixed vs Knock
- Apple: Sending notification requests to APNs, for
apns-collapse-idandapns-id - Google: Collapsible messages and the FCM HTTP v1 reference, for
collapse_key