<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Espresso Notify</title>
    <description>The latest articles on DEV Community by Espresso Notify (@espressonotify).</description>
    <link>https://dev.to/espressonotify</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4215860%2F161d84f2-acda-4ea2-a39e-8f17bfd9c205.png</url>
      <title>DEV Community: Espresso Notify</title>
      <link>https://dev.to/espressonotify</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/espressonotify"/>
    <language>en</language>
    <item>
      <title>Why your push notification didn't arrive: the FCM and APNs errors, and the failures nobody reports</title>
      <dc:creator>Espresso Notify</dc:creator>
      <pubDate>Thu, 29 Oct 2026 21:20:28 +0000</pubDate>
      <link>https://dev.to/espressonotify/why-your-push-notification-didnt-arrive-the-fcm-and-apns-errors-and-the-failures-nobody-reports-8hg</link>
      <guid>https://dev.to/espressonotify/why-your-push-notification-didnt-arrive-the-fcm-and-apns-errors-and-the-failures-nobody-reports-8hg</guid>
      <description>&lt;p&gt;A push notification has two lives. In the first, your server asks Firebase Cloud Messaging (FCM) or Apple's APNs to deliver it, and gets an answer. In the second, a phone somewhere decides whether to show it. Most debugging goes wrong because people read the first answer as if it described the second.&lt;/p&gt;

&lt;p&gt;This is a guide to both: what the errors from FCM (HTTP v1) and APNs mean, which ones to retry, which ones to treat as "delete this token", and the failures that come back with no error at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Accepted" is not "delivered"
&lt;/h2&gt;

&lt;p&gt;When APNs answers &lt;code&gt;200&lt;/code&gt;, it accepted your request. When FCM answers with a message name, it accepted your request. A web push service answering &lt;code&gt;201 Created&lt;/code&gt; is the same: RFC 8030 says the message "was accepted", nothing more.&lt;/p&gt;

&lt;p&gt;Apple's documentation does not give the sending server a signal that the notification was displayed. FCM is more generous but still separates the two: in its BigQuery export, &lt;code&gt;MESSAGE_ACCEPTED&lt;/code&gt; means the FCM server received a valid request, while &lt;code&gt;MESSAGE_DELIVERED&lt;/code&gt; means it reached the FCM SDK on the device, and that field is not exported by default. FCM's delivery reports also depend on Google Analytics and can lag by up to 24 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For your pipeline:&lt;/strong&gt; store "accepted by provider" and "delivered/opened" as different facts. Accepted comes from the provider's answer. Delivered and opened have to come from your app reporting back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sort every error into one of four buckets
&lt;/h2&gt;

&lt;p&gt;Before the table, a rule that saves hours. Every provider error is one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The token is dead.&lt;/strong&gt; Delete it and never send to it again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your request is wrong.&lt;/strong&gt; Fix it; retrying the same request repeats the failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your credentials or configuration are wrong.&lt;/strong&gt; Fix the setup; it affects many devices at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The provider is struggling or you are going too fast.&lt;/strong&gt; Retry later, with exponential backoff and jitter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Mixing the buckets is how teams end up deleting healthy tokens during an outage, or retrying a dead token forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  FCM (HTTP v1)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNREGISTERED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;The token is no longer valid: the app was uninstalled, unregistered, or the token expired&lt;/td&gt;
&lt;td&gt;Delete the token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;A parameter is invalid: a malformed token, wrong package name, message too big, reserved data key, invalid TTL&lt;/td&gt;
&lt;td&gt;Read the &lt;code&gt;BadRequest&lt;/code&gt; detail. Fix the message. Delete the token only after confirming the payload is valid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SENDER_ID_MISMATCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;The sender you authenticated as is not the sender the token was registered for&lt;/td&gt;
&lt;td&gt;Check that your service account belongs to the same Firebase project as the app. Do not delete tokens first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QUOTA_EXCEEDED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;Rate exceeded for the project, one device, or one topic&lt;/td&gt;
&lt;td&gt;Reduce the rate. FCM recommends exponential backoff with an initial delay of at least 1 minute for message and topic rates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNAVAILABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;503&lt;/td&gt;
&lt;td&gt;FCM is overloaded&lt;/td&gt;
&lt;td&gt;Retry the same request, honor &lt;code&gt;Retry-After&lt;/code&gt; if present, back off exponentially, add jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INTERNAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;Unknown FCM error&lt;/td&gt;
&lt;td&gt;Retry with backoff; check the FCM status dashboard; if it persists, contact Firebase support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;THIRD_PARTY_AUTH_ERROR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;The APNs certificate or web push auth key is invalid or missing, for a message to an iOS device or a web registration&lt;/td&gt;
&lt;td&gt;Fix the APNs or web push credentials in the Firebase console&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three details from the official docs that are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;INVALID_ARGUMENT&lt;/code&gt; is the ambiguous one. FCM's own token guide says it can mean an invalid registration &lt;em&gt;or&lt;/em&gt; a bad payload, and tells you to treat it as a dead token only when you have confirmed the payload is fine. Log the field detail before you delete anything.&lt;/li&gt;
&lt;li&gt;The size limit is 4096 bytes for most messages and 2048 bytes for messages to topics, keys and values included. TTL must be an integer between 0 and 2,419,200 seconds (4 weeks).&lt;/li&gt;
&lt;li&gt;On Android, FCM expires a registration after 270 days of inactivity. On iOS it relies on APNs, which has no such expiry, so Firebase recommends you prune stale tokens yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  APNs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;&lt;code&gt;reason&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BadDeviceToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The token is invalid, or does not match the environment&lt;/td&gt;
&lt;td&gt;First check sandbox vs production. Only then treat it as dead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DeviceTokenNotForTopic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The token does not belong to the topic (bundle ID) you sent&lt;/td&gt;
&lt;td&gt;Fix the &lt;code&gt;apns-topic&lt;/code&gt;. Apple lists it among the errors not to retry as-is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TopicDisallowed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pushing to this topic is not allowed&lt;/td&gt;
&lt;td&gt;Check that the topic is allowed for your app and key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExpiredProviderToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your JWT is stale&lt;/td&gt;
&lt;td&gt;Generate a new one and retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;&lt;code&gt;InvalidProviderToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The JWT is invalid or its signature can't be verified&lt;/td&gt;
&lt;td&gt;Check the key, Key ID and Team ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;410&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Unregistered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The token is no longer active for the topic; the response includes a &lt;code&gt;timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stop sending to it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PayloadTooLarge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The payload is over the limit&lt;/td&gt;
&lt;td&gt;Shrink it. 4 KB (4096 bytes), 5 KB for VoIP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TooManyRequests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Too many requests to the same device token&lt;/td&gt;
&lt;td&gt;Retry with a delay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TooManyProviderTokenUpdates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You refresh your JWT too often&lt;/td&gt;
&lt;td&gt;Refresh at most once every 20 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 / 503&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;InternalServerError&lt;/code&gt;, &lt;code&gt;ServiceUnavailable&lt;/code&gt;, &lt;code&gt;Shutdown&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Apple-side trouble&lt;/td&gt;
&lt;td&gt;Retry with backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rules in Apple's own words are worth keeping near your code: don't retry &lt;code&gt;BadDeviceToken&lt;/code&gt;, &lt;code&gt;DeviceTokenNotForTopic&lt;/code&gt;, &lt;code&gt;Forbidden&lt;/code&gt;, &lt;code&gt;ExpiredToken&lt;/code&gt;, &lt;code&gt;Unregistered&lt;/code&gt; or &lt;code&gt;PayloadTooLarge&lt;/code&gt;; you can retry &lt;code&gt;TooManyRequests&lt;/code&gt; with a delay; 5xx responses can be retried, and Apple mentions waiting 15 minutes and using back-off.&lt;/p&gt;

&lt;p&gt;Two more things from that page. Errors in the 4xx range slow you down: APNs can disconnect a provider connection that produces too many of them, and sooner for &lt;code&gt;BadDeviceToken&lt;/code&gt;. Sending to dead tokens is not only wasteful, it throttles your good traffic. And &lt;code&gt;BadDeviceToken&lt;/code&gt; has a classic cause: a token from an Xcode (development) build belongs to the sandbox environment, while TestFlight and App Store builds belong to production. Send to the wrong host and every token of the other kind fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For your pipeline:&lt;/strong&gt; map each code to a bucket in one place (a lookup table, not scattered &lt;code&gt;if&lt;/code&gt;s), and log the raw code next to your own classification. When a new reason shows up, the default should be "don't delete, don't retry blindly, alert".&lt;/p&gt;

&lt;h2&gt;
  
  
  Failures that come back without an error
&lt;/h2&gt;

&lt;p&gt;This is the part the tables can't help with. The provider said yes, and the user saw nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notifications are off for the app.&lt;/strong&gt; On Android 13 and later, notifications are off by default for a new install, and if the user taps Don't allow, all notification channels are blocked. The token stays valid and the send keeps being accepted. FCM even warns that high-priority messages that produce no visible notification can be deprioritized over a 7-day window. Check &lt;code&gt;areNotificationsEnabled()&lt;/code&gt; in the app and report it to your server along with the token. Do Not Disturb and Focus modes work the same way from your server's point of view: the system decides what to show, and nothing tells you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doze and priority.&lt;/strong&gt; FCM's normal-priority messages may be held back while the device is in Doze; high priority is the option meant for urgent, user-visible content. APNs has the same idea: &lt;code&gt;apns-priority&lt;/code&gt; 10 means now, 5 means "when power considerations allow", and 5 or 1 can be grouped into bursts or throttled. Pick the priority per message type, not one global default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry while the phone is offline.&lt;/strong&gt; With no TTL, FCM can keep a message for up to four weeks; with a TTL of 0 it discards what it can't deliver immediately. APNs with &lt;code&gt;apns-expiration: 0&lt;/code&gt; tries once and doesn't store the notification. If your "flash sale ends in 1 hour" push sat in a queue for a day, it should have expired, so set TTL on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android vendors that kill apps.&lt;/strong&gt; Some manufacturers aggressively stop background apps to save battery. The community site dontkillmyapp.com rates Xiaomi, Samsung, OnePlus and Huawei at its worst level. You can't fix this from the server, but you can detect the pattern: if your delivery stats look fine overall and bad for one device brand, this is the first suspect. Link users to the vendor-specific steps from your support page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expo Go.&lt;/strong&gt; If you test with Expo Go, remote push is not available there on Android from SDK 53; Expo says a development build is required, while local notifications still work in Expo Go. "It works in my build and not in Expo Go" is expected behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web push.&lt;/strong&gt; Three things trip people up. On iOS the permission request must come from direct user interaction, and asking from a button is the safe default everywhere. The push needs a registered service worker. And on iOS and iPadOS 16.4 and later, web push works for web apps added to the Home Screen, not for a plain Safari tab. When a subscription has expired, RFC 8030 says the push service answers &lt;code&gt;404&lt;/code&gt;: delete it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A debugging order that works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Did the provider accept the request? Read the status and &lt;code&gt;reason&lt;/code&gt;/error code, not just "success".&lt;/li&gt;
&lt;li&gt;Is the token alive? Check how old it is and when the app last registered it.&lt;/li&gt;
&lt;li&gt;Right environment? Sandbox vs production for APNs, right Firebase project for FCM.&lt;/li&gt;
&lt;li&gt;Is the payload under the limit, with a sensible TTL and priority?&lt;/li&gt;
&lt;li&gt;Is the app allowed to show notifications on that device (permission, channel, focus modes)?&lt;/li&gt;
&lt;li&gt;Is the device a known battery-killer, or a build that can't receive remote push (Expo Go)?&lt;/li&gt;
&lt;li&gt;If you control the app, did it report receipt? If not, you are guessing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  About the project
&lt;/h2&gt;

&lt;p&gt;I work on Espresso Notify, a push notification service for iOS, Android and web with a dashboard and a REST API, and the cases above shaped how it reports a failure. Each failed device gets a stable code and what to do about it instead of a bare provider string, tokens the provider says are dead are removed so the next send skips them, and the docs state plainly that "sent" means the provider accepted it. It is free up to 10,000 devices.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How delivery results are reported: &lt;a href="https://docs.espressonotify.com/docs/sending/verify-delivery" rel="noopener noreferrer"&gt;https://docs.espressonotify.com/docs/sending/verify-delivery&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Setting up the iOS key, including the sandbox/production choice: &lt;a href="https://docs.espressonotify.com/docs/credentials/apns" rel="noopener noreferrer"&gt;https://docs.espressonotify.com/docs/credentials/apns&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A question for the comments: when FCM returns &lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;, how do you decide between "bad payload" and "dead token" in your pipeline? Do you parse the &lt;code&gt;BadRequest&lt;/code&gt; detail, or do you wait for a second failure?&lt;/p&gt;

</description>
      <category>pushnotifications</category>
      <category>mobile</category>
      <category>firebase</category>
      <category>ios</category>
    </item>
    <item>
      <title>Retries without duplicate sends: idempotency keys, atomic transitions and the double click</title>
      <dc:creator>Espresso Notify</dc:creator>
      <pubDate>Thu, 29 Oct 2026 21:19:58 +0000</pubDate>
      <link>https://dev.to/espressonotify/retries-without-duplicate-sends-idempotency-keys-atomic-transitions-and-the-double-click-35l4</link>
      <guid>https://dev.to/espressonotify/retries-without-duplicate-sends-idempotency-keys-atomic-transitions-and-the-double-click-35l4</guid>
      <description>&lt;p&gt;You send a request that triggers an e-mail, a push notification, an SMS or a payment. The connection drops. You do not know whether the server received it. Do you send it again?&lt;/p&gt;

&lt;p&gt;If you retry, you may send the message twice. If you don't, you may send it zero times. Over a network, the honest answer is that you must retry, so the real work is making a repeat harmless. This article covers the four places where duplicates come from and the pattern that closes each one: the HTTP boundary, the queue, the state change and the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why retrying is not optional
&lt;/h2&gt;

&lt;p&gt;A request can fail in three ways that look identical to the client: the request never arrived, it arrived and failed, or it arrived and succeeded but the response was lost. The client sees a timeout in all three. If your API sends something irreversible, "just retry" is a bug waiting for the third case, and "never retry" is an outage waiting for the first.&lt;/p&gt;

&lt;p&gt;The fix is not smarter retries. It is making the operation &lt;strong&gt;idempotent&lt;/strong&gt;: doing it twice has the same effect as doing it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Idempotency keys at the HTTP boundary
&lt;/h2&gt;

&lt;p&gt;The contract has four rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The client generates the key&lt;/strong&gt;, before the first attempt, and sends the same key on every retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server stores the first result&lt;/strong&gt; next to the key and replays it for later calls with the same key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same key, same body: replay.&lt;/strong&gt; Do not run the effect again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same key, different body: error.&lt;/strong&gt; The client has a bug (it reused a key for a new intent) and silently replaying the old result would hide it. A &lt;code&gt;422&lt;/code&gt; makes it loud.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key should name the &lt;strong&gt;event&lt;/strong&gt;, not the attempt: &lt;code&gt;order-1042-shipped&lt;/code&gt; is good, a fresh UUID generated inside the retry loop protects nothing. If there is no natural event id, generate a UUID once per user intent and keep it until the call succeeds.&lt;/p&gt;

&lt;p&gt;On the server, the unique constraint does the hard part. Two identical requests arriving at the same moment cannot both insert the same key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;idempotency_keys&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;project_id&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;message_id&lt;/span&gt;   &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scope the key to a tenant (here &lt;code&gt;project_id&lt;/code&gt;), so two customers choosing &lt;code&gt;welcome-1&lt;/code&gt; don't collide. Hash a canonical form of the body (sorted keys, no whitespace) so equivalent JSON compares equal.&lt;/p&gt;

&lt;p&gt;The handler claims the key and creates the effect in the same transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acceptSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canonicalJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO idempotency_keys (project_id, key, request_hash)
     VALUES ($1, $2, $3)
     ON CONFLICT (project_id, key) DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`SELECT request_hash, message_id FROM idempotency_keys
       WHERE project_id = $1 AND key = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;request_hash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idempotency_key_reused&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duplicate_ignored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message_id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// 202&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO messages (project_id, payload, status)
     VALUES ($1, $2, 'queued') RETURNING id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`UPDATE idempotency_keys SET message_id = $3
     WHERE project_id = $1 AND key = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// 202&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, a concurrent request with the same key waits on the unique index until the first transaction commits, then takes the conflict branch and reads the committed row. Because the key and the message commit together, there is no state where the key exists and the message doesn't.&lt;/p&gt;

&lt;p&gt;Two details worth deciding on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't consume the key on a refused request.&lt;/strong&gt; If validation fails with a &lt;code&gt;400&lt;/code&gt;, store nothing, so the client can fix the body and reuse the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The effect is a row, not a call.&lt;/strong&gt; The handler enqueues the work; a worker sends it later. Calling the provider inside the request would put a network call between "claimed" and "committed".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a key without a unique constraint is a suggestion. Make the database refuse the second insert.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The client: retry only what can succeed later
&lt;/h2&gt;

&lt;p&gt;The client half is a loop that generates the key once and retries only answers where the same request might succeed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RETRYABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;postOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;RETRYABLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ApiError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a 4xx will not change on retry&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// no answer at all: same key, try again&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gave up after retries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Called once per intent, not once per attempt:&lt;/span&gt;
&lt;span class="nf"&gt;postOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v1/messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`order-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-shipped`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules behind it: retry &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;5xx&lt;/code&gt; with growing, randomized waits; honor &lt;code&gt;Retry-After&lt;/code&gt; when present; retry a timeout with the same key; &lt;strong&gt;never&lt;/strong&gt; retry a &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;404&lt;/code&gt; or &lt;code&gt;422&lt;/code&gt;, because the answer will be the same. Add jitter, or a thousand clients that failed together will retry together.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. At-least-once queues need idempotent consumers
&lt;/h2&gt;

&lt;p&gt;Most queues promise at-least-once delivery. A worker that crashes after doing the work but before acknowledging it gets the same message again. That's a feature, but it means the consumer has to be idempotent too.&lt;/p&gt;

&lt;p&gt;When the effect is in your own database, put the dedupe record in the same transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_messages&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mailer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- if 0 rows were inserted: ROLLBACK and acknowledge, already done&lt;/span&gt;
&lt;span class="c1"&gt;-- otherwise: apply the effect, then COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the effect lives outside (an e-mail provider, a push service), you can't share a transaction. Two options: pass the provider a key if it supports one, and record "sent" with the provider's id as soon as you get it. For push specifically there is also a safety net: APNs lets you set &lt;code&gt;apns-collapse-id&lt;/code&gt; to merge notifications into one, and FCM's collapse key replaces a pending message with the same key for the same token. These don't replace idempotency, since they only help while the earlier message is still waiting, but they limit the damage of a duplicate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; assume every message arrives twice and write the consumer so that's boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Atomic state transitions: approvals and refunds
&lt;/h2&gt;

&lt;p&gt;Some duplicates have nothing to do with the network. Two admins click Approve on the same refund. A webhook is delivered twice. The naive code reads the status, checks it, then writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refund&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// two requests can both pass this check&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;payOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix it with a compare-and-set: let the database check and change the status in one statement, and use the number of affected rows as the verdict.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`UPDATE refunds
      SET status = 'approved', approved_by = $2, approved_at = now()
    WHERE id = $1 AND status = 'pending'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;adminId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`INSERT INTO payouts (refund_id) VALUES ($1)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// we won: do the effect&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT status FROM refunds WHERE id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refund_not_pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// already approved by someone else: report success, do nothing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly one caller sees &lt;code&gt;rowCount === 1&lt;/code&gt;. The loser gets a clear answer, and doing the payout in the same transaction means it can't be skipped or doubled. If you prefer ORM-style updates, a &lt;code&gt;version&lt;/code&gt; column checked in the &lt;code&gt;WHERE&lt;/code&gt; gives the same guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; don't check state in application code and write in another statement. Put the expected state in the &lt;code&gt;WHERE&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The UI: a button that can only be pressed once
&lt;/h2&gt;

&lt;p&gt;Most duplicates start with a human. Double click, slow network, impatient second tap. Three layers, cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disable the button while the request is pending.&lt;/li&gt;
&lt;li&gt;Don't let the framework retry mutations behind your back. In TanStack Query, mutations are not retried on error by default, but set &lt;code&gt;retry: false&lt;/code&gt; explicitly so a global default change doesn't silently turn a POST into a repeat.&lt;/li&gt;
&lt;li&gt;Create the key once per intent, so even a double click that slips through sends the same key twice.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ShipButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// one key per intent&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ship&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMutation&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mutationFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ship&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ship&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ship&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Mark as shipped
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;disabled&lt;/code&gt; only updates after a render, so two clicks can land in the same frame; the &lt;code&gt;isPending&lt;/code&gt; guard and the shared key are what actually protect you. &lt;code&gt;crypto.randomUUID()&lt;/code&gt; needs a secure context (HTTPS).&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The client generates the key from the event and reuses it on every retry.&lt;/li&gt;
&lt;li&gt;A unique constraint on &lt;code&gt;(tenant, key)&lt;/code&gt;; the key and the effect commit together.&lt;/li&gt;
&lt;li&gt;Same key plus different body is a &lt;code&gt;422&lt;/code&gt;, not a replay.&lt;/li&gt;
&lt;li&gt;Retry &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;5xx&lt;/code&gt; and timeouts, with backoff, jitter and &lt;code&gt;Retry-After&lt;/code&gt;. Never retry other &lt;code&gt;4xx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Queue consumers dedupe by message id in the same transaction as the effect.&lt;/li&gt;
&lt;li&gt;State changes are compare-and-set; &lt;code&gt;rowCount&lt;/code&gt; decides who won.&lt;/li&gt;
&lt;li&gt;UI: disabled while pending, &lt;code&gt;retry: false&lt;/code&gt; on mutations, one key per intent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  About the project
&lt;/h2&gt;

&lt;p&gt;I work on Espresso Notify, a push notification service for iOS, Android and web with a dashboard and a REST API. Its send routes follow this contract, with the key as a field of the JSON body, &lt;code&gt;idempotency_key&lt;/code&gt; (up to 128 bytes). Repeating a send with the same key and body answers &lt;code&gt;202&lt;/code&gt; with &lt;code&gt;duplicate_ignored&lt;/code&gt; and the id of the first send; the same key with another body answers &lt;code&gt;422 idempotency_key_reused&lt;/code&gt;; a refused call stores nothing, so the same key works once you fix the request. The docs include the retry table (&lt;code&gt;429&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt;, &lt;code&gt;5xx&lt;/code&gt;, timeouts with the same key) and the Node library adds a random key per call when you don't pass one. It is free up to 10,000 devices.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The contract and the retry table: &lt;a href="https://docs.espressonotify.com/docs/api/reliable-sending" rel="noopener noreferrer"&gt;https://docs.espressonotify.com/docs/api/reliable-sending&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The Node library's retries and key: &lt;a href="https://docs.espressonotify.com/docs/server-libraries/node" rel="noopener noreferrer"&gt;https://docs.espressonotify.com/docs/server-libraries/node&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A question for the comments: where do you keep the line between "the same request" and "a different body" when you compare bodies for a key? Canonical JSON hash, a subset of fields, or something else? And how do you handle a key whose first request is still in flight when the retry arrives?&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>architecture</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Push notifications for React Native and the web, priced per device instead of per message</title>
      <dc:creator>Espresso Notify</dc:creator>
      <pubDate>Thu, 29 Oct 2026 20:19:31 +0000</pubDate>
      <link>https://dev.to/espressonotify/push-notifications-for-react-native-and-the-web-priced-per-device-instead-of-per-message-36m4</link>
      <guid>https://dev.to/espressonotify/push-notifications-for-react-native-and-the-web-priced-per-device-instead-of-per-message-36m4</guid>
      <description>&lt;p&gt;Most push notification services charge by the message or by the monthly active user, and both grow faster than your app. We built Espresso Notify around a simpler unit: the active device. A device counts when it used your app in the last 30 days; send it one push or fifty, the price is the same. The first 10,000 devices are free.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you get
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One dashboard&lt;/strong&gt; to write a campaign once and send it to iOS, Android and browsers together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audiences&lt;/strong&gt;: segments by properties and behavior, plus test devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery you can trust&lt;/strong&gt;: every send shows delivered, opened and failed per device, with the failure reason (for example a token the device no longer uses).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A REST API&lt;/strong&gt; for transactional pushes, with idempotency keys so a retry never sends twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrails&lt;/strong&gt;: quiet hours and frequency capping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Journeys and A/B tests&lt;/strong&gt; for the campaigns that need more than one message.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Setting up a React Native app
&lt;/h3&gt;

&lt;p&gt;The React Native SDK ships on npm as &lt;code&gt;@espresso-notify/react-native&lt;/code&gt;. One command sets it up in Expo apps and in React Native CLI apps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @espresso-notify/react-native@latest setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wizard reads your &lt;code&gt;package.json&lt;/code&gt;. In an Expo project (Expo SDK 53 or newer) it installs the packages that match your Expo SDK and adds the config plugin. In a React Native CLI project (React Native 0.79 or newer) it installs the Firebase and notification packages, adds the lines your Podfile, Gradle files and AppDelegate need, and lists what is left for you, such as your Firebase files.&lt;/p&gt;

&lt;p&gt;From there the quickstarts walk through registering the device, identifying the user and sending a first push from the dashboard: &lt;a href="https://docs.espressonotify.com/docs/react-native/expo" rel="noopener noreferrer"&gt;https://docs.espressonotify.com/docs/react-native/expo&lt;/a&gt; for Expo and &lt;a href="https://docs.espressonotify.com/docs/react-native/bare" rel="noopener noreferrer"&gt;https://docs.espressonotify.com/docs/react-native/bare&lt;/a&gt; for React Native CLI. Web push has its own guide in the same docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why per device
&lt;/h3&gt;

&lt;p&gt;Per-message pricing punishes the apps that talk to their users the most, and MAU pricing counts people who never get a notification. Counting active devices matches what actually costs money to run, and it is easy to predict: you know how many devices installed your app last month.&lt;/p&gt;

&lt;p&gt;Try it free at &lt;a href="https://espressonotify.com" rel="noopener noreferrer"&gt;https://espressonotify.com&lt;/a&gt;. We would love to hear what is hard about push for your team today.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>webdev</category>
      <category>notifications</category>
    </item>
  </channel>
</rss>
