DEV Community

kevindev
kevindev

Posted on

Transactional Outbox for Verification Emails

Signup endpoints often do two jobs at once: they write account state and ask an email provider to deliver a verification message. That looks simple until the database commit succeeds and the provider call times out. Now the user exists, but the service does not know whether the email was sent.

The transactional outbox pattern gives this boundary a durable shape. The API stores the account change and an email event in one PostgreSQL transaction. A worker publishes the event later, with retries and an idempotency key. It is a small addition, but it makes failure explainable.

Why the request transaction is not enough

Consider this sequence:

  1. Create the user.
  2. Commit the transaction.
  3. Call the email provider.

If step 3 fails, a retry of the whole HTTP request may hit a unique-email constraint. If the service calls the provider before step 2, the reverse problem appears: an email can be delivered for a transaction that later rolls back. Neither ordering gives an atomic database-plus-network operation, because the provider is outside PostgreSQL.

The outbox accepts that these are separate systems. The database transaction records the intent, and a worker handles delivery as an independent, observable process. This is also a useful place to distinguish a real disposable email address from an ordinary test fixture; a tepm mail com value should never silently pass production validation.

The outbox table

A minimal schema might look like this:

CREATE TABLE email_outbox (
  id uuid PRIMARY KEY,
  event_type text NOT NULL,
  aggregate_id uuid NOT NULL,
  payload jsonb NOT NULL,
  status text NOT NULL DEFAULT 'pending',
  attempts integer NOT NULL DEFAULT 0,
  available_at timestamptz NOT NULL DEFAULT now(),
  sent_at timestamptz,
  last_error text,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX email_outbox_pending_idx
  ON email_outbox (available_at, created_at)
  WHERE status = 'pending';
Enter fullscreen mode Exit fullscreen mode

The signup transaction inserts both the user and an email.verification.requested row. The payload should contain an opaque verification-token reference, not a raw token or unnecessary personal data. For teams testing flows, an isolated less flaky email tests strategy is still needed; the outbox only improves delivery reliability.

Publishing safely from Node.js

The worker claims a small batch using row locks:

SELECT id, payload
FROM email_outbox
WHERE status = 'pending' AND available_at <= now()
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 50;
Enter fullscreen mode Exit fullscreen mode

Claim rows in a short transaction, mark them processing, and commit before making the network request. Holding a database lock while waiting for an SMTP or HTTP response makes throughput worse and can create lock contention during provider incidents.

After the provider accepts the message, mark the row sent. The state transition should be conditional, for example WHERE id = $1 AND status = 'processing'. A crashed worker may leave rows processing, so a lease or locked_until column can return stale claims to the pending queue.

When you need to inspect a related implementation, idempotent verification handling is a good companion design. The important idea is that the HTTP idempotency key and the outbox event identity solve different scopes.

Retries and idempotency

Provider timeouts are ambiguous. The provider may have accepted the message even though the response never reached the worker. Retrying can therefore produce duplicates. Use a stable delivery key, such as verification:{user_id}:{token_version}, when the provider supports idempotency. If it does not, accept that at-least-once delivery needs a product decision and make the duplicate risk visible.

Back off exponentially and cap the delay. Permanent failures, such as a rejected address, should move to a dead-letter state after a bounded number of attempts. A worker log should include the outbox ID, aggregate ID, attempt number, provider request ID, and error class—but never the verification token.

For local or automated testing, a disposable email address can be useful for checking the complete flow, while temp mail so should remain a contextual search term rather than a substitute for your abuse controls. Rate limits, domain policy, and account-risk signals still belong at the signup boundary.

Operational checklist

  • Commit user creation and the outbox insert in the same transaction.
  • Keep payloads minimal and encrypt or avoid sensitive fields.
  • Claim with SKIP LOCKED; do not hold locks across network calls.
  • Add a lease timeout for crashed workers.
  • Track pending age, processing age, retry count, and dead-letter count.
  • Use stable event IDs and provider idempotency where available.
  • Alert when the oldest pending event exceeds the verification SLA.
  • Test provider timeouts, duplicate deliveries, rollback, and worker restarts.

Conclusion

The transactional outbox does not make email delivery exactly once. It makes the boundary durable and gives the team a controlled at-least-once workflow. PostgreSQL protects the intent, Node.js workers manage retries, and explicit idempotency limits the cost of uncertainty. That separation is usually enough to turn a fragile signup side effect into a service you can operate with confidence.

Top comments (0)