DEV Community

kevindev
kevindev

Posted on

Idempotent Signup Emails in Node.js APIs

Signup email bugs are rarely caused by SMTP alone. In most backend systems, the real problem is that account creation, retries, and background jobs do not agree on what "sent once" means. I have seen well-structured services still send two welcome messages because the API timed out after commit, the worker retried, and nobody had a durable delivery key tying the steps together.

Why duplicate signup mail still happens in good systems

The risky part is usually the boundary between the write path and the notification path. A POST /signup request inserts a user, stores a verification token, and publishes a job. If the HTTP layer retries or the worker crashes after send, you can end up with duplicate mail even though each subsystem looks healthy on its own.

In practice, I keep seeing four causes:

  1. The API uses a request id for logs, but not for email deduplication.
  2. The worker checks "has token" instead of "has delivery record".
  3. Retries are safe for database writes, but not for side effects.
  4. Teams test with a shared inbox, so duplicate messages are easy to miss at first glance.

This matters because signup email is often the first trust signal a product sends. If a user receives two verification links, or one old link after a retry, confidence drops realy fast.

The backend contract I use for idempotent email delivery

The most reliable pattern I have used is simple: create a dedicated outbox record inside the same transaction as the user and token write, then let the mail worker claim that record exactly once.

My minimum contract looks like this:

  • users stores the account row.
  • email_verifications stores the active token and expiry.
  • email_outbox stores the message type, recipient, payload hash, and a unique idempotency key.
  • The worker updates sent_at with WHERE sent_at IS NULL so retries do not double-send.

That last condition is the boring detail that saves you later. If the process dies after the provider accepts the message, a retry will re-read the row, see sent_at, and move on. If the provider call fails before acceptance, the row stays unsent and the worker can retry cleanly.

For staging and QA, I sometimes route confirmation mail to a use and throw email inbox created per test run. When I need a second isolated check in the same suite, a separate disposable address helps keep concurrent workers from reading each other's messages. The old phrase dummy e mail still sneaks into test names in some repos, but I prefer explicit naming around signup intent and environment.

A Node.js implementation that survives retries

Here is the shape I tend to keep in a Node.js service:

await db.tx(async (trx) => {
  const user = await trx.one(
    `insert into users (email, password_hash)
     values ($1, $2)
     on conflict (email) do update set email = excluded.email
     returning id, email`,
    [email, passwordHash]
  );

  const verification = await trx.one(
    `insert into email_verifications (user_id, token, expires_at)
     values ($1, $2, now() + interval '30 minutes')
     returning token`,
    [user.id, token]
  );

  await trx.none(
    `insert into email_outbox (event_key, user_id, template, payload)
     values ($1, $2, 'signup_verification', $3)
     on conflict (event_key) do nothing`,
    [`signup:${user.id}:${verification.token}`, user.id, { token: verification.token }]
  );
});
Enter fullscreen mode Exit fullscreen mode

Then the worker claims pending rows in small batches:

update email_outbox
set claimed_at = now()
where id in (
  select id
  from email_outbox
  where sent_at is null
    and claimed_at is null
  order by created_at
  for update skip locked
  limit 50
)
returning *;
Enter fullscreen mode Exit fullscreen mode

I like this approach because the API stays quick, the worker stays retry-friendly, and PostgreSQL does the concurrency control work it is already very good at. The 2024 DORA report keeps reinforcing the value of short, reliable feedback loops in delivery systems, and this pattern helps because failures stay local and debuggable instead of turning into weird inbox heisenbugs later: https://dora.dev/research/.

One small but useful rule: log the outbox id, user id, and template name together on every provider call. When support asks why a customer got no message, or got two, you can answer in minuts instead of diffing scattered traces.

How I test the flow without polluting shared inboxes

For signup flows, I do not just assert that an email arrived. I assert that one and only one valid message arrived for the right event key.

That test usually covers:

  • one API request that succeeds on the first try
  • one simulated retry from the client or gateway
  • one worker retry after an injected transport failure
  • one inbox assertion that checks message count and token freshness

If you already do release email checks in CI or other forms of isolated inbox validation, the same principle applies here: isolate the mailbox per run, then verify the backend contract instead of trusting the latest message you happen to see.

Shared QA inboxes are where teams get tricked. A fake emails generator can be useful for quick manual checks, but for automated suites I want deterministic inbox ownership and a message count assertion tied to the event key. Otherwise, parallel tests hide duplicates until a customer reports them.

Q&A

Should I send mail inline during signup?

Usually no. If the provider is slow, your REST API latency becomes noisy and retries become harder to reason about. A transactional outbox is more boring, and that is exactly why it works.

What should the idempotency key include?

Include the event type plus the business identity of the send. For signup verification, I usually key by user id and token or by a stable signup event id. Do not key only by email address, or you will block legitimate future sends.

Is PostgreSQL enough, or do I need a queue first?

PostgreSQL is enough for many products if the write volume is moderate and the worker batches cleanly. Add a separate queue when throughput, fan-out, or cross-service routing actually demands it, not before. Starting simple is often the beter trade.

Top comments (0)