Skip to content
  • Architecture
  • BullMQ
  • Slotwise

Never losing an email: the outbox pattern in a booking system

Exactly-once email is impossible, so Slotwise aims for at-least-once with deduplication: an outbox table, deterministic job ids, a send log and a separate worker.

Toukir Ahmed Rony · · 3 min read

Every booking in Slotwise sends emails: a confirmation, a reminder, and a cancellation if plans change. Customers notice quickly when one goes missing. They also notice when they get the same email twice.

Here's how I made sure emails are never lost and practically never sent twice.

Exactly once is not possible

I'll start with the honest part. Exactly-once delivery to an SMTP server is impossible in general. A crash can always land between "the mail server accepted it" and "we wrote down that it did".

So the design is at-least-once with deduplication. Keep trying until it's sent, and stop duplicates wherever we can.

Why not send it in the request?

The simple version sends the email inside the booking request. That has two problems. It's slow, because the customer waits for SMTP. And if the process crashes after the booking commits but before the email goes, the email is lost.

Pushing a job to Redis inside the database transaction doesn't fix it either. That's two systems with no shared commit. One can succeed while the other fails.

The outbox

The fix is the outbox pattern. When a booking changes, the API writes the change and a row in outbox_events in the same transaction. They commit together or not at all.

Right after the commit, the API turns the event into jobs. That's the fast path.

A relay in the worker is the safety net. Every 5 seconds it claims unprocessed events with FOR UPDATE SKIP LOCKED and queues them. If the API crashed between commit and enqueue, the relay picks the event up. Nothing is lost.

Deterministic job ids

If both the fast path and the relay queue the same event, I don't want two emails. So every job gets an id built from what it is, by one helper:

email-confirm-<booking>-v<version>
reminder-<booking>
webhook-<event>-<endpoint>

The same event queued twice becomes one job.

One small lesson: BullMQ rejects : in custom ids. The original brief had ids like reminder:ID, so those were out, and the helper uses hyphens only. A test runs the helper's ids through real BullMQ.

The send log

Job ids stop duplicate jobs. They don't stop one job running twice after a retry. For that there's email_log, with a unique key on (booking, kind, version). The worker checks it before sending and writes a row after SMTP accepts.

Stale jobs

A reminder can sit in the queue for days. In that time the customer might move or cancel. So jobs carry the booking version. Before sending, the worker reads the booking again. If it was moved or cancelled since, the job sends nothing.

Never trust Redis alone

PostgreSQL is the source of truth, not Redis. An hourly reconcile job re-creates any missing reminder. It uses the same id, so it does nothing when the reminder already exists. If a reminder was lost inside its 24-hour window, reconcile sends it straight away.

A separate worker

All of this runs in a separate worker process. It's the same NestJS codebase as the API, started from src/worker.ts. I didn't make a separate apps/worker app, because that would duplicate modules and config for no benefit. Slow SMTP or webhook retries never block API requests.

The one gap I accept

One window is left. If the worker crashes after SMTP accepts an email but before the email_log row is written, the customer can get one duplicate. Every other failure is either retried or deduplicated. I wrote that down in the README rather than pretend it isn't there.

How I know it works

The tests cover the cases that matter:

  • the relay picks up an event after a lost fast path;
  • double processing queues only once;
  • a stale reminder sends nothing;
  • reconcile restores a deleted reminder;
  • the send log stops a repeat.

They run against real PostgreSQL and Redis, with no database mocks.

The full design is in the Slotwise README and its decision notes.