DEV Community

kevindev
kevindev

Posted on

PostgreSQL Outboxes for Signup Emails

When signup emails start missing in production, the bug is usualy not inside the SMTP provider. It is often in the handoff between your REST API, your database write, and the worker that sends the message later. That is why I keep coming back to a PostgreSQL outbox design for signup mail instead of pushing directly to a queue in the request path.

The reason is boring in a good way. If the user row commits but the queue publish fails, support gets a "created account, no email" ticket. If the client retries during a slow network hop, you may enqueue two welcome messages. If staging uses random inboxes and nobody ties them back to one request id, debugging gets messy real fast. An outbox row makes those cases more tractable.

Why signup email sends fail in boring ways

The request flow looks simple:

  1. POST /signup
  2. create user
  3. generate verification token
  4. queue email job
  5. return 201

What breaks is not the happy path, it is the seam between steps 3 and 4. Queue publish calls can timeout after your transaction committed. API clients can retry even when the first response was already on the wire. Workers can process the same job twice after a crash. None of that is dramatic, but it adds the kind of edge-case noise engineers hate cleaning up at 2 a.m.

I also see staging searches get muddy because people leave notes like tamp mail com or temp gamil com in tickets and shell history. That is not the root cause, but it is a sign the verification process is fuzzy and too dependent on manual inbox chasing.

The PostgreSQL outbox shape I keep coming back to

I prefer storing the email intent in the same transaction as the user and token write. A minimal outbox table can be:

create table email_outbox (
  id bigserial primary key,
  event_type text not null,
  aggregate_id uuid not null,
  dedupe_key text not null unique,
  payload jsonb not null,
  status text not null default 'pending',
  available_at timestamptz not null default now(),
  sent_at timestamptz,
  created_at timestamptz not null default now()
);
Enter fullscreen mode Exit fullscreen mode

The important field is dedupe_key. For signup mail, I usually build it from a stable operation id such as signup:<user_id>:verify-email:v1. If the client retries the same request, the insert will conflict cleanly instead of creating another pending email. That ends up working nicely beside patterns like concurrency keys for email checks, because both approaches force the system to name one operation clearly.

Then the write transaction stays boring:

await db.tx(async (trx) => {
  const user = await createUser(trx, input);
  const token = await createVerifyToken(trx, user.id);

  await trx.none(
    `insert into email_outbox
       (event_type, aggregate_id, dedupe_key, payload)
     values ($1, $2, $3, $4)
     on conflict (dedupe_key) do nothing`,
    [
      "signup.verify_email",
      user.id,
      `signup:${user.id}:verify-email:v1`,
      { userId: user.id, tokenId: token.id, email: user.email }
    ]
  );
});
Enter fullscreen mode Exit fullscreen mode

I like this because the API promise is modest and honest. If the transaction commits, the intent to send exists durably in PostgreSQL. You can let a worker poll the outbox, or stream it with logical decoding later if your system grows up and needs it. Do the simple thing first.

A REST API flow that stays idempotent under retries

The request handler should not say "email sent". It should say "signup accepted, email delivery scheduled" and return enough identifiers to debug later. I tend to include:

  • user_id
  • operation_id
  • email_delivery_state

The worker then claims pending rows with for update skip locked, sends one message, and updates status to sent or failed. PostgreSQL documents SKIP LOCKED as a way to avoid waiting on rows already locked by another transaction, which is exactly why it fits small outbox workers so well (https://www.postgresql.org/docs/current/sql-select.html).

What I try to avoid is pushing transport-specific details into the API contract too early. Provider message ids are useful, but they should be attached to the outbox record, not replace it. Support and staging tools need one stable handle from the app domain first. That also makes a privacy review for magic-link flows much easier, because you can audit what data left the system without scraping random worker logs.

How I verify the pipeline in staging

My staging check is very small:

  1. create the signup through the public API
  2. fetch the outbox or delivery record by operation id
  3. wait for a terminal state
  4. inspect the rendered email in a temporary inbox

That last step matters, but I do not want the inbox to be the only truth source. Teams often search for something like temp mail so when they really need to answer a backend question: did we persist the send intent, and did exactly one worker consume it? The inbox confirms rendering and transport. PostgreSQL confirms system behavior. You need both, or at least you probly do once the app has real traffic.

I also keep the worker batch size small in staging. Five rows at a time is enough for most checks, and it makes failure review much easier than draining fifty mixed events in one loop.

Q&A

Why not publish directly to the queue inside the request?

You can, but then your API success path depends on two systems committing in one moment. The outbox reduces that coupling and gives you a recovery surface when the queue or provider is having a weird day.

Does this only help Node.js apps?

Not at all. I use Node.js a lot, but the pattern is database-first. Any stack that can write the business row and outbox row in one transaction can use it.

When should I move past polling workers?

Only when polling is proven too slow or too expensive. For most signup email volume, a simple poller with clear metrics is easier to run, easier to fix, and honestly less fancy in the best way.

If your signup flow keeps losing email intent at the edges, an outbox table is one of the highest-leverage boring fixes I know. It keeps the REST API contract honest, lets PostgreSQL do the coordination work it is already good at, and makes email debugging feel a lot less random.

Top comments (0)