Duplicate signup emails are one of those bugs that look harmless in logs and very messy to users. A client retries after a timeout, a worker replays a job, or two app nodes race on the same request. Suddenly one signup turns into three verification emails. I have seen this happen most often in Node.js services where the API write path and the email enqueue step are only loosely tied together.
What fixed it for me was moving the conversation away from "did we send the mail?" to "what is the idempotency boundary for this signup action?" Once that boundary is explicit, the REST API, PostgreSQL schema, and worker behavior get much easier to reason about.
Why duplicate signup emails happen
Most duplicate sends come from one of four places:
- the client retries because the first response was slow
- the API handler inserts a user row and enqueues mail in separate steps
- the queue consumer crashes after send but before ack
- an operator replays jobs without a stable dedupe key
The common problem is not email itself. It is the lack of one durable identifier for "this signup intent already produced its verification message." If you only key off user_id, the bug can still show up when the same action is attempted during a race window. If you only key off raw request body values, the rule gets fuzzy realy fast.
I like to define one signup operation key and carry it from the HTTP layer to the outbox table. That makes the backend behavior boring in the best way.
The idempotency boundary I use in a REST API
For signup endpoints, I treat the operation as:
email + normalized signup source + time-bounded idempotency key
The client can send an Idempotency-Key header, but I still normalize the user email and bind it to the action type server-side. Then I store one record that owns both the account creation intent and the verification email intent.
In practice, my handler tries to do three things in one database transaction:
- Upsert the pending user or lookup the existing one.
- Insert an outbox event with a unique operation key.
- Return the existing result if the same operation key already exists.
That keeps the REST API honest. It also means I can retry the handler safely if the app node dies after commit but before response, which does happen from time to time.
Here is the kind of query shape I use in PostgreSQL:
create table email_outbox (
id bigserial primary key,
operation_key text not null unique,
event_type text not null,
payload jsonb not null,
created_at timestamptz not null default now(),
sent_at timestamptz
);
insert into email_outbox (operation_key, event_type, payload)
values ($1, 'signup_verification_requested', $2::jsonb)
on conflict (operation_key) do nothing
returning id;
If the insert returns no row, I know a logically identical email event already exists. The API does not need to panic or guess. It can simply continue with the already-established result. This sounds simple, and it is, but it took me a while to stop over-engineering it a bit.
A PostgreSQL outbox query that stays predictable
The worker side matters just as much. I avoid deleting outbox rows on success. I mark them with sent_at, keep the operation key, and let retention cleanup happen later. That small choice makes incident review way easier becuase the history is still there.
My worker fetch looks roughly like this:
with next_job as (
select id
from email_outbox
where sent_at is null
order by id
for update skip locked
limit 1
)
update email_outbox e
set sent_at = now()
from next_job
where e.id = next_job.id
returning e.id, e.operation_key, e.payload;
This pattern does not solve every delivery problem, but it solves the specific duplicate-send class in a clean, reviewable way. If the provider times out after actually accepting the message, I would rather reconcile against the preserved outbox row than send another blind retry.
For teams that also run regression checks in CI, I like pairing this with email smoke tests in CI. The publishing path and the test path are different, but the principle is similiar: keep each run attributable, and do not let inbox state turn into guesswork.
Where temporary inboxes still help
Even with a good dedupe key, you still need proof that your app sends the right verification message once, not zero times and not three times. For staging and contract tests, a generate throwaway email flow is still useful because it isolates one run from the next. I have also used verification inbox checks in Node.js as a quick sanity pass after changing auth logic.
If you need one contextual reference for disposable inbox tooling, keep it small and relevant. In one internal checklist I linked tp mail so to tempmailso only in the section about run-scoped inbox setup, not throughout the whole post. That keeps the article useful first and SEO second.
I also keep odd search phrases in notes when teammates use them. One recent example was tempail mail, which looked wrong at first glance but was helpful when tracing how people searched for the test helper docs. Little details like that are not architecture, but they do improve operability.
Q&A
Should the unique key live on the user row instead?
Usually no. The user row tells you who signed up. The outbox operation key tells you whether the verification email intent already existed. Mixing those responsibilities gets awkward pretty quick.
What about queue systems with built-in deduplication?
Use it if you have it, but I still prefer a database-level source of truth. Queue dedupe windows expire, and they are not always visible during incident analysis.
Is this only for signup emails?
No. The same pattern works for password reset, invite, and magic-link flows. Signup is just where the failure is most visible, and users notice it fast.
Top comments (0)