The Outbox Pattern
- nestjs
- postgresql
- nodejs
- backend
- system-design
- software-architecture
- event-driven
- distributed-systems
Last time in the NestJS Production Guide, I covered Event-Driven Architecture, and closed on a loose end: publishing an event after a database write isn't atomic unless you make it so. If your process crashes, or the broker is briefly down, between the commit and the publish, you get a state change with no corresponding event. Nobody gets notified, nothing downstream reacts, and there's no log entry telling you it happened.
The outbox pattern is the standard fix for that gap, and it doesn't need new infrastructure beyond what you already have: a transactional database and a way to poll or stream from it. I want to walk through why the dual-write problem is real, how to implement an outbox table in a NestJS service backed by PostgreSQL, and where the pattern earns its keep versus where it's overkill.
The dual-write problem
Picture a straightforward order-placement flow. A command handler writes a new Order row, then calls this.eventBus.publish(new OrderPlacedEvent(order.id)) so a notification service can email the customer and an analytics service can log the conversion. Two operations, two different systems: the database and the message broker (or in-memory event bus, if you haven't split things out yet).
Those two operations don't share a transaction. There's no way to wrap "commit this row" and "publish this message" in one atomic unit when they go to physically different systems. That leaves a handful of failure modes, none of them good:
- The database commit succeeds, then the process crashes before the publish call runs. The order exists, nobody was told.
- The publish call throws because the broker is briefly unreachable. Retrying the whole command risks creating a second order if that retry re-runs the write too.
- The publish succeeds but the transaction rolls back afterward. Now you've told the world about an order that doesn't exist.
These tend to surface under load or during a deploy, exactly when they're hardest to debug. The outbox pattern removes the second system from the equation at write time. Instead of writing to the database and publishing to the broker as two steps, you write the order and a row describing the event to the same database, in the same transaction. Publishing becomes a separate, asynchronous step that reads from that outbox table and is safe to retry, because it isn't the source of truth. The database is.
How the outbox pattern works
The mechanism has three parts:
- An outbox table, in the same PostgreSQL database as your domain tables, that stores pending events as rows.
- A single transaction per business operation that writes both the domain change and the outbox row.
- A relay process that reads unpublished rows from the outbox, sends them to your broker or event bus, and marks them as sent.
Because the domain write and the outbox write happen in one transaction, they succeed or fail together. If it commits, the event is guaranteed to be sitting in the table, waiting for the relay. If it rolls back, there's no orphaned event, because the outbox row never committed either. A dual-write problem becomes a single-write problem, plus an asynchronous, retryable delivery step.
Here's the schema, using Prisma against PostgreSQL:
// prisma/schema.prisma
model OutboxEvent {
id String @id @default(uuid())
aggregateId String
eventType String
payload Json
createdAt DateTime @default(now())
processedAt DateTime?
@@index([processedAt, createdAt])
}
The processedAt column is null for anything the relay hasn't shipped yet, and the composite index keeps the relay's polling query cheap even as the table grows.
The command handler writes both rows in one transaction:
// src/modules/orders/commands/handlers/place-order.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { PlaceOrderCommand } from '../place-order.command';
import { PrismaService } from '../../../../prisma/prisma.service';
@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
constructor(private readonly prisma: PrismaService) {}
async execute(command: PlaceOrderCommand): Promise<string> {
const orderId = await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
customerId: command.customerId,
totalCents: command.totalCents,
status: 'PLACED',
},
});
await tx.outboxEvent.create({
data: {
aggregateId: order.id,
eventType: 'OrderPlaced',
payload: {
orderId: order.id,
customerId: order.customerId,
totalCents: order.totalCents,
},
},
});
return order.id;
});
return orderId;
}
}
Nothing here talks to a message broker. The handler's only job is committing domain state and a description of what happened, atomically, to PostgreSQL. Delivery is the relay's problem.
The relay: getting events out of the table
The relay is a separate, small piece of code that polls the outbox table on an interval, publishes anything unprocessed, and marks rows as done. NestJS's built-in scheduling makes this easy for a first version:
// src/modules/outbox/outbox-relay.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../prisma/prisma.service';
import { EventPublisherService } from '../events/event-publisher.service';
@Injectable()
export class OutboxRelayService {
private readonly logger = new Logger(OutboxRelayService.name);
private isRunning = false;
constructor(
private readonly prisma: PrismaService,
private readonly eventPublisher: EventPublisherService,
) {}
@Cron(CronExpression.EVERY_SECOND)
async relayPendingEvents(): Promise<void> {
if (this.isRunning) {
return;
}
this.isRunning = true;
try {
const pending = await this.prisma.outboxEvent.findMany({
where: { processedAt: null },
orderBy: { createdAt: 'asc' },
take: 100,
});
for (const event of pending) {
await this.eventPublisher.publish(event.eventType, event.payload);
await this.prisma.outboxEvent.update({
where: { id: event.id },
data: { processedAt: new Date() },
});
}
} catch (error) {
this.logger.error('Outbox relay failed', error instanceof Error ? error.stack : error);
} finally {
this.isRunning = false;
}
}
}
Two details matter more than they look. The isRunning guard stops overlapping runs once a batch takes longer than a second, which happens as volume grows. And the await between publish and update is deliberate: if the publish throws, the row stays unprocessed and gets retried on the next tick. That's what makes this at-least-once delivery, and it's why consumers downstream (the notification service, the analytics handler) need to be idempotent. In rare failure windows a row can be published successfully and then fail to update as processed, and get published again.
If polling a busy table starts showing up in your database load, the next step is PostgreSQL's logical replication via a tool like Debezium, which tails the write-ahead log instead of running repeated queries. That's a real jump in operational complexity (a Kafka Connect deployment and a connector to run and monitor). Wait until polling causes a measured bottleneck before reaching for it.
Tradeoffs and where it doesn't pay off
The outbox pattern buys at-least-once delivery of events guaranteed to correspond to a real, committed state change. It doesn't buy exactly-once delivery, cross-aggregate ordering, or low latency. A polling relay running every second introduces up to a second of lag between commit and message, and even a log-tailing relay lags slightly while catching up on the WAL.
Weigh that lag against your use case. Notifications and analytics usually tolerate events arriving a second or two late. Live collaborative cursors or real-time pricing updates don't, and the relay's polling interval becomes a real constraint there. For that kind of workload, publish directly and accept the small risk of a dropped message, or use a different mechanism entirely.
The second cost is the extra table and relay process itself, another moving part to monitor when a notification goes missing: was the domain write correct, did the outbox row get created, did the relay pick it up, did the consumer process it. For a module where a dropped event is a minor inconvenience (an unsent marketing email), the outbox pattern is more ceremony than the problem deserves. Reach for it where a dropped event is a correctness bug: billing state, inventory counts, anything a downstream system trusts as the record of what happened.
Production pitfalls
The outbox table grows forever unless you clean it up. Once an event is marked processedAt, it's done its job; letting it sit indefinitely just makes the relay's polling query and your backups larger over time. A scheduled job that deletes processed rows older than a retention window (a few days is usually generous) keeps the table small and the index effective.
A stuck relay is invisible until someone notices events aren't arriving. If the process crashes and orchestration doesn't restart it, or a bug throws on every iteration while the isRunning guard never releases, events pile up silently. Alert on the age of the oldest unprocessed row instead of relay uptime alone. A relay that's technically running but stuck failing the same batch looks healthy on a liveness check while events accumulate behind it.
Payload shape drift bites teams that don't version their events. If OrderPlacedEvent's payload changes shape six months from now and old, unprocessed rows still carry the previous shape, your consumer needs to handle both, or you need a migration step before deploying the new consumer. Treat the outbox payload as a versioned contract, not an implementation detail you can casually reshape.
Finally, keep business logic out of the relay. Its only job is reading rows and calling a publisher. Conditional logic based on payload contents, deciding what to do rather than just delivering what already happened, quietly moves domain logic into infrastructure code that's harder to test than a command handler.
Key takeaways
- The outbox pattern solves the dual-write problem: a database commit and a message publish can't share a transaction when they go to different systems, so failures between them silently drop events.
- Write the domain change and an outbox row in the same database transaction. Delivery to the broker becomes a separate, retryable step, not part of the write path.
- A relay process polls the outbox table for unprocessed rows and publishes them, marking each as done only after a successful publish. This gives at-least-once delivery, so consumers must be idempotent.
- Polling on an interval is a reasonable starting point. Move to log-tailing with something like Debezium only once polling load or lag is a measured problem.
- Clean up processed rows on a retention window, monitor the age of the oldest unprocessed row (not just relay uptime), and version your event payloads.
- Skip the pattern where a dropped event is a minor inconvenience. Use it where a dropped event is a correctness bug that a downstream system depends on.
Frequently asked questions
What problem does the outbox pattern actually solve?
It solves the dual-write problem: writing to a database and publishing to a message broker can't share a transaction, so a crash or broker outage between them can drop an event even though the database write succeeded. Writing the event as a row in the same transaction as the domain change makes the write atomic and moves delivery to a separate, retryable step.
Does the outbox pattern guarantee exactly-once delivery?
No, it guarantees at-least-once delivery. A row can be published successfully but fail to be marked as processed, causing it to be published again on the next relay run. Consumers need to be idempotent, typically by checking an event ID or using an operation safe to apply twice, like an upsert.
Do I need Kafka or a message broker to use the outbox pattern?
No. The outbox table lives in your existing PostgreSQL database, and the relay can publish to anything: an in-memory event bus, Redis pub/sub, SQS, Kafka, or a webhook call. The pattern is about getting the event out of the database reliably, not which transport you use afterward.
How is the outbox pattern different from @nestjs/cqrs's EventBus?
`@nestjs/cqrs`'s in-memory `EventBus` publishes events synchronously within the same process and doesn't persist anything. If the process crashes right after a handler calls `eventBus.publish()`, the event is gone with no trace. The outbox pattern persists the event to the database in the same transaction as the write, so it survives a crash and can be recovered by a relay, at the cost of some latency.
How do I decide between polling and Debezium-style log tailing for the relay?
Start with a polling relay on a short interval; it's simple to build, debug, and reason about, and it's enough for most workloads. Move to log tailing only once polling causes measurable database load or the lag it introduces is a real problem, not because it sounds more sophisticated.
What happens to old rows in the outbox table?
They should be deleted on a retention schedule once marked processed. A table that grows unbounded slows the relay's polling query and bloats backups. A daily cleanup job removing rows older than a few days, once you've confirmed nothing downstream needs to replay further back, keeps the table healthy.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.