How to Prevent Duplicate Emails in Your Application: 5 Common Causes and Fixes
A user signs up once. They receive two welcome emails, just a few seconds apart. Or two "your payment was received" emails. Or, worst of all, two password reset emails, where the first link is no longer valid.
Duplicate emails rarely originate from the email provider itself. In most cases, they happen because your application triggers the same email more than once.
In modern application architectures, there are at least five common reasons this happens.
This guide explains each one, how to identify it from your logs, and how to prevent it using practical code examples.
First: Identify Why Your Email Is Being Sent Twice
Before changing your code, check how much time passes between the two emails.
Log every email request with:
- Recipient email address
- Event that triggered the email
- Timestamp of the request
- Application or job ID
- Email provider's message ID
- Idempotency key, if available
Then compare the timestamps of duplicate emails.
| Time between duplicate emails | Most likely cause |
|---|---|
| A few milliseconds, usually in development | React Strict Mode running an effect twice (Bug 1) |
| Less than a second | Double-click or duplicate form submission (Bug 2) |
| Seconds to minutes, sometimes repeated later | Webhook redelivery (Bug 3) |
| Around your function timeout (10s, 30s, 60s, etc.) | Serverless or queue retry (Bug 4) |
| At the same scheduled interval or minute | Overlapping cron jobs (Bug 5) |
These problems can overlap, but identifying the timing gives you a good starting point.
Bug 1: Sending Emails Inside React useEffect (Strict Mode)
One common mistake is sending emails directly from a React component using useEffect().
For example:
// Don't do this "use client"; import { useEffect } from "react"; export default function Welcome({ user }) { useEffect(() => { fetch("/api/send-welcome", { method: "POST", body: JSON.stringify({ userId: user.id }), }); }, [user.id]); return <h1>Welcome, {user.name}!</h1>; } In development, React Strict Mode intentionally runs an additional setup and cleanup cycle for effects to help identify bugs.
This can cause the email API request to execute twice.
But the problem isn't limited to development.
In production, refreshing the page, opening another tab, or navigating back to the page can trigger the same effect again.
As a result, the user may receive multiple welcome emails.
The fix: Trigger emails from server-side business events, not page renders.
An email should be sent when something happens, such as an account being created, rather than whenever a component renders.
For example, in Next.js:
// app/signup/actions.js "use server"; import { redirect } from "next/navigation"; export async function signUp(formData) { const user = await createUser(formData); // Send the welcome email after successful account creation await sendWelcomeEmail(user); redirect("/welcome"); } For production systems, also protect this operation with an idempotency mechanism so repeated signup requests cannot send duplicate emails.
If duplicate emails only appear during development and the gap is a few milliseconds, React Strict Mode is worth investigating first.
Don't disable Strict Mode to hide the problem. Fix the side effect instead.
Bug 2: Double Clicks and Duplicate Form Submissions
Imagine a user signing up or completing checkout.
The network is slow, so they click the submit button twice.
If your application processes both requests independently, it may execute the same operation twice and send two emails.
This commonly happens when:
- Users click the submit button multiple times.
- The network connection is slow.
- The frontend doesn't disable the button while submitting.
- Browsers or proxies retry requests.
- Mobile networks resend requests after temporary failures.
The first fix: Disable the submit button while the request is processing.
In React:
"use client"; import { useFormStatus } from "react-dom"; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? "Creating account..." : "Sign up"} </button> ); } This improves the user experience, but it isn't enough to guarantee that the server processes an operation only once.
The real fix: Add server-side idempotency.
You should protect your application at two levels:
- Use database constraints to prevent duplicate business operations. For example, enforce a unique email address for user accounts or a unique order ID for payments.
- Use an idempotency key when sending emails so repeated requests for the same event don't create additional messages.
For example:
async function sendWelcomeEmail(user) { await fetch("https://api.cresca.xyz/api/v1/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, "Content-Type": "application/json", // The same user gets only one welcome email "Idempotency-Key": `welcome/${user.id}`, }, body: JSON.stringify({ to: user.email, template: { id: "welcome", variables: { NAME: user.firstName, }, }, }), }); } The important part is the idempotency key:
"Idempotency-Key": `welcome/${user.id}` It is based on the business event, not the current timestamp or a random value.
Therefore, if the same operation runs again, it uses the same key.
Your email API should recognize that key and return the original result instead of creating another email, within its documented idempotency window.
Always check your provider's idempotency implementation and expiration policy.
Bug 3: Webhooks Are Delivered More Than Once
Webhooks are another major reason applications send duplicate emails.
Imagine this workflow:
- A customer completes a payment.
- Stripe sends an
invoice.paidwebhook. - Your application sends a payment confirmation email.
- Your webhook endpoint takes too long to respond.
- Stripe retries the webhook.
- Your application sends the same confirmation email again.
Why does this happen?
Webhook providers generally use at-least-once delivery.
This means the same event can be delivered multiple times, especially when your endpoint times out, returns an unsuccessful response, or fails to acknowledge the event.
Stripe, for example, documents that webhook events can be delivered more than once and retries failed deliveries.
The fix: Track processed webhook events using their unique event IDs.
For PostgreSQL, you can create a table:
CREATE TABLE processed_webhooks ( event_id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', received_at TIMESTAMPTZ NOT NULL DEFAULT now() ); When a webhook arrives, verify its signature and register the event before processing it.
// app/api/webhooks/payments/route.js export async function POST(request) { const event = await verifyAndParse(request); // Register the event only once const claimed = await db.query( `INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id`, [event.id] ); if (claimed.rowCount === 0) { return new Response("Already received", { status: 200, }); } // Process the event asynchronously through a worker await paymentQueue.add( "process-payment", { event }, { jobId: event.id, } ); return new Response("Accepted", { status: 200, }); } The worker should send the email using an idempotency key based on the actual business object:
if (event.type === "invoice.paid") { const invoice = event.data.object; await sendReceipt(invoice, { idempotencyKey: `receipt/${invoice.id}`, }); } This matters because different webhook events can sometimes represent the same business operation.
For example, a payment provider might send both checkout.session.completed and invoice.paid.
Using the invoice ID instead of the webhook event ID helps prevent both events from producing the same receipt.
Two additional things to remember:
- Return a successful response quickly. Don't perform slow email operations inside the webhook request.
- Use a durable queue or transactional outbox so an event isn't permanently marked as received if queue insertion or processing fails.
A production implementation should also track processing states and retry failed jobs rather than treating every registered event as successfully processed.
Bug 4: A Timeout Happens, Then the Email Is Sent Again
This is particularly common in serverless applications and background job systems.
Consider this situation:
- Your application calls the email API.
- The email provider successfully accepts the message.
- Your function times out before receiving or returning the response.
- The queue or serverless platform assumes the operation failed.
- The same job runs again.
- The customer receives another email.
The application cannot always know whether the first attempt succeeded.
This can happen with:
- BullMQ and other queues: Jobs can be retried after failures or stalled processing.
- AWS Lambda: Failed asynchronous invocations can be retried automatically.
- Cloud Tasks: Tasks can be retried when they aren't successfully acknowledged.
- Custom retry logic: Your own code may repeat an operation after a network error.
The easiest way to identify this problem is to compare the time between duplicate emails with your configured function or job timeout.
The fix: Reuse the same idempotency key for every retry.
For example:
async function sendPaymentConfirmation(payment) { const response = await fetch( "https://api.cresca.xyz/api/v1/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `payment/${payment.id}`, }, body: JSON.stringify({ to: payment.customerEmail, template: { id: "payment-confirmation", variables: { AMOUNT: payment.amount, }, }, }), } ); if (!response.ok) { throw new Error("Email request failed"); } return response.json(); } Notice that the idempotency key is based on the payment ID.
Every retry uses the same key.
If the first request was successful but your application never received its response, the next request can return the original result instead of sending another email.
Also investigate why the timeout happened.
Sending one email shouldn't normally require a long-running operation. Check for slow database queries, unnecessary API calls, or other work happening before or after the email request.
Bug 5: Overlapping Cron Jobs
Scheduled jobs can also send duplicate emails.
Imagine you have a cron job that runs every 10 minutes:
"Find users who haven't received a welcome email and send it."
Now imagine two instances of your application run the scheduler at almost the same time.
Both jobs query the database and find the same users before either one updates their records.
Both send the welcome email.
The problem is the order of operations:
Read → Send → Update
For example:
// Problematic implementation const users = await db.query( "SELECT * FROM users WHERE welcome_sent_at IS NULL" ); for (const user of users.rows) { await sendWelcomeEmail(user); await db.query( "UPDATE users SET welcome_sent_at = now() WHERE id = $1", [user.id] ); } Two overlapping jobs can read the same users and send duplicate emails.
The fix: Claim records atomically before processing them.
With PostgreSQL, you can use FOR UPDATE SKIP LOCKED to let multiple workers safely claim different records.
For example:
WITH claimed_users AS ( SELECT id FROM users WHERE welcome_status = 'pending' ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED ) UPDATE users SET welcome_status = 'processing', welcome_claimed_at = now() WHERE id IN (SELECT id FROM claimed_users) RETURNING id, email, first_name; Now, only one worker can claim each user at a time.
After successfully sending the email, mark the operation as completed:
for (const user of rows) { try { await sendWelcomeEmail(user, { idempotencyKey: `welcome/${user.id}`, }); await db.query( `UPDATE users SET welcome_status = 'sent', welcome_sent_at = now() WHERE id = $1`, [user.id] ); } catch (error) { console.error("Welcome email failed:", user.id, error); // Record the failure so it can be retried safely await db.query( `UPDATE users SET welcome_status = 'pending' WHERE id = $1`, [user.id] ); } } For a production system, also implement a processing lease or timeout so records claimed by a worker that crashes can eventually be recovered.
The important idea is to claim the work before sending the email, rather than updating the database only after sending.
The Fix for All Five Problems
If there's one thing to remember from this article, it's this:
An email belongs to a business event, and your application must know whether that event has already triggered an email.
Here are five practical rules:
- Send emails from the server: Trigger emails from business events, not React components, page renders, or client-side effects.
- Use idempotency keys: Generate predictable keys from business events, such as
welcome/<user-id>orreceipt/<invoice-id>. - Protect operations at the database level: Use unique constraints, processed-event records, and atomic claims to prevent duplicate processing.
- Make retries safe: Network errors, timeouts, and queue retries should reuse the original idempotency key.
- Keep detailed logs: Record every email request, its triggering event, idempotency key, and provider message ID.
One important distinction: idempotency keys on an email API and database-level deduplication solve different problems. Use both for reliable email delivery. Also remember that no application can guarantee exactly-once delivery across every external system without carefully managing failures and recovery.
I'm building Cresca, an email platform that combines transactional email APIs with AI-powered email marketing campaigns.
With Cresca, developers can send transactional emails using API requests, idempotency keys, reusable templates, and per-email delivery tracking. Teams can also create and manage marketing campaigns using AI, all from the same account.
If you've experienced duplicate emails in your application, I'd love to hear what caused the issue and how you fixed it in the comments.

