NestJS Logo

Outbox

When a customer places an order at an online store for cat food and supplies, the order API saves the order and announces it with an OrderPlaced event. Three things react to that event: a confirmation email goes out, stock is reserved, and a separate analytics service records the sale.

Saving the order and publishing the event are two writes to two different systems, and no transaction spans both:

  • Save, then publish. If the process crashes or the broker is down between the two, the order exists but OrderPlaced is lost. The customer gets no email, the stock isn't reserved, and analytics undercounts.
  • Publish, then save. If the insert fails, the customer is thanked for an order that doesn't exist.
  • Retry the publish on error. A timeout after a successful send looks like a failure, so the retry sends it again and the customer gets two emails.

This is the dual-write problem. ClientProxy.emit(), EventEmitter2 and the CQRS EventBus all publish from memory, so they all have it.

A transactional outbox removes it by turning the event into a row. Outbox.add() inserts the message into an outbox table in the same database transaction as the order, so both commit or neither does. A relay then publishes committed messages. It retries failures with backoff and moves messages that never succeed to a dead-letter table. Retries mean a consumer can see a message twice, so each consumer keeps an inbox of the message ids it has processed and skips repeats.

In this tutorial, you'll add the outbox to the store's order API, on PostgreSQL with Drizzle:

  • POST /orders saves the order and its messages in one transaction.
  • Two in-process handlers send the confirmation email and reserve stock.
  • A separate analytics microservice, with a database of its own, receives order events over TCP, in order for each order.
  • Failures are retried, and a small admin API lists, requeues and purges dead letters.
  • Several instances of the API share the work safely.

Prerequisites#

  • PostgreSQL, with Drizzle ORM on the pg driver, registered through @nestjs/drizzle. The outbox's messages commit with your rows, so they live in the database your orders live in, on a database server that outlives any instance of the application. Using TypeORM, Prisma or MySQL? Follow the tutorial for what the application does: Keep messages in your database shows the store with each of them.
  • @nestjs/microservices, for the TCP link to the analytics service in Publish to the analytics microservice.

Install the package:


$ npm i --save @nestjs/outbox

The example application, the online store's order API, keeps its products and orders in two tables of its own, which the later sections read and write. They're the example's data, not something the outbox needs: the outbox's tables belong to its store, which creates them, as Keep messages in your database shows.

products is the catalog, and reserving stock for an order moves units from its in_stock to its reserved:

ColumnTypeNotes
idtextPrimary key, such as salmon-kibble-2kg
nametextThe display name, such as Salmon kibble, 2 kg
priceintegerIn cents
in_stockintegerThe units available to order
reservedintegerThe units held for placed orders. Defaults to 0

orders holds the orders that POST /orders saves:

ColumnTypeNotes
idtextPrimary key
user_idtextThe customer who placed the order
itemsjsonbThe order's lines, as a JSON array: each line's productId, quantity and unit price (in cents)
totalintegerIn cents
statustextplaced or cancelled

No column in either table is nullable.

The catalog has two products: salmon-kibble-2kg, at 2499 cents with 10 in stock, and clumping-litter-10l, at 1599 cents with one in stock.

Keep messages in your database#

There's nothing to create for the outbox: no tables, entities or Prisma models. Register its store, and the store creates its own schema, nest_outbox, and migrates it: at startup in development and in tests, and in production with npx nest-outbox migrate, or with migrationSql() in your own migrations.

The outbox keeps its messages, its dead letters and the consumers' inboxes in the same database as your own tables, because they must commit with your rows. Until you register a store for them, the outbox keeps them in memory: fine for a first run, but a restart loses every message that wasn't published yet, two instances don't share them, and nothing joins your transactions.

Warning With NODE_ENV=production and no store registered, startup fails, unless you set allowInMemoryStorage: true.

On PostgreSQL, register the package's store, PostgresOutboxStore from @nestjs/outbox/postgres. It runs its SQL through the database client your application already has, so it can join your transactions, and it keeps its tables in a schema of its own. It's an ordinary provider, registered once, in the root module, next to OutboxModule: both are application-wide, and every outbox.add() in any module goes through it. The root module creates it with a factory that injects the Drizzle database and the OutboxStorage registry, which the store registers itself with:

app.module.ts
JS TS

{
  // Messages and inbox records in your database, in a schema of their own (nest_outbox)
  provide: PostgresOutboxStore,
  inject: [getDrizzleToken(), OutboxStorage],
  useFactory: (db: Database, outboxStorage: OutboxStorage) =>
    new PostgresOutboxStore({ executor: fromDrizzle(db) }, outboxStorage),
},

fromDrizzle(db) is the store's executor: it runs the store's statements through your Drizzle database, whichever driver it uses, such as pg or PGlite. fromPg(pool), fromTypeOrm(dataSource), fromPrisma(prisma) and fromKysely(db) do the same for a node-postgres pool, TypeORM, Prisma and Kysely. At startup, the module logs OutboxStorage: PostgresOutboxStore.

The schema. The store keeps its tables in a schema of its own, nest_outbox (the schema option names another). On MySQL, which has no schemas, they're tables of the connection's database, with the schema's name as their prefix: nest_outbox_messages and so on.

TableWhat it holds
nest_outbox.messagesEach message from add() until it's published or dead-lettered, with its attempts and lease
nest_outbox.dead_lettersThe messages that failed, with the reason and the error of every attempt
nest_outbox.inboxThe ids of the messages each consumer has processed
nest_outbox.migrationsThe versions of the store's schema applied

They belong to the store: your migrations don't create them, your ORM's schema doesn't declare them, and your own tables can't collide with them. drizzle-kit and Prisma Migrate work on the public schema unless told otherwise, and TypeORM on its entities' tables, so their tools leave nest_outbox alone.

Migrations. The package ships the schema as versioned migrations, and the store applies them itself. With the migrate option, it applies the ones the schema hasn't had yet when the application starts, before the relay runs, in one transaction that holds an advisory lock: of several instances that start together, one migrates, and the others find nothing left to do. migrate defaults to true, except with NODE_ENV=production. So in development and in tests, the store creates its schema on the first start, and your migrations only create your own tables, here products and orders.

In production, apply the migrations before the new version of your application starts, as you apply your own, so that no instance changes the schema while it boots: in the middle of a rolling deploy, that would lock busy tables, and a least-privilege database user can't do it anyway. The package's command line does it from your deploy step, next to npx drizzle-kit migrate. It reads the database from DATABASE_URL, or from --url, and needs pg installed:


$ npx nest-outbox status
Schema "nest_outbox" is at version 0; this version of @nestjs/outbox needs version 1.
$ npx nest-outbox migrate
Migrated schema "nest_outbox" to version 1 (applied 1).

status exits with 1 while the schema is behind, which makes it a check for CI. To apply the migrations with your own migration tool instead, take their SQL from npx nest-outbox sql, or from PostgresOutboxStore.migrationSql() in code: the statements of every migration, one per paragraph, with the bookkeeping that records the version. Run them in one transaction, as TypeORM's and Drizzle's migrators do. With TypeORM, that's a migration of your own, next to the ones it generates for your entities:

typeorm/migrations/1790801276314-OutboxStore.ts
JS TS

import { PostgresOutboxStore } from '@nestjs/outbox/postgres';
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class OutboxStore1790801276314 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(PostgresOutboxStore.migrationSql());
  }

  // The store's migrations only ever add tables, columns and indexes: it has no down migrations.
  async down(): Promise<void> {}
}

With drizzle-kit, create a custom migration, and fill it with the SQL, a --> statement-breakpoint line between the statements: Drizzle's migrator then runs them one at a time, which PGlite requires. In code, migrationSql() with statementBreakpoints: true returns the same.


$ npx drizzle-kit generate --custom --name=outbox
$ npx nest-outbox sql --statement-breakpoints > drizzle/0002_outbox.sql

On MySQL, MySqlOutboxStore.migrationStatements() returns the statements one per string, and a TypeORM migration runs them one per call:

typeorm/mysql-migrations/1790801276314-OutboxStore.ts
JS TS

for (const statement of MySqlOutboxStore.migrationStatements()) {
  await queryRunner.query(statement);
}

With migrate off, a store whose schema is behind fails the startup with an OutboxSchemaError that names these three ways, and fails every call the same way until the schema catches up, which it notices without a restart. Migrations only ever add tables, columns and indexes, so during a rolling deploy, the previous version of your application keeps running on the migrated schema. There are no down migrations.

Transactions.outbox.add() takes your transaction, as Save the order and its messages in one transaction shows, and so does a handler's processInTransaction(): with Drizzle, the tx that db.transaction() hands its callback. The store runs its statements on that transaction, so they commit or roll back with your writes. It refuses the database itself, whose statements would commit on their own, with an OutboxTransactionRequiredError that says what to pass. With the other executors, pass a node-postgres client after BEGIN (not the pool), TypeORM's EntityManager from dataSource.transaction(), Prisma's interactive transaction client, or a Kysely transaction.

Isolation. The store's own transactions (claims, dead-letter moves, requeues) rely on READ COMMITTED, PostgreSQL's default isolation level: at startup, the store checks the database's default_transaction_isolation, and fails if it's stricter. Yours may run at any level. add() first takes a lock per key on your transaction, so transactions that add messages with the same key take turns, and a key's messages are numbered in the order their transactions commit. Under REPEATABLE READ or SERIALIZABLE, two deliveries of one message that race to processInTransaction() can fail the second with a serialization error instead of skipping it as a duplicate: its handler fails, and the relay's retry finds the record.

With TypeORM.TypeOrmModule takes the place of DrizzleModule in the root module, and the store runs on the DataSource it provides, through fromTypeOrm():

typeorm/app.module.ts
JS TS

{
  // Messages and inbox records in your database, in a schema of their own (nest_outbox)
  provide: PostgresOutboxStore,
  inject: [DataSource, OutboxStorage],
  useFactory: (dataSource: DataSource, outboxStorage: OutboxStorage) =>
    new PostgresOutboxStore({ executor: fromTypeOrm(dataSource) }, outboxStorage),
},

Where the tutorial passes Drizzle's tx to outbox.add(), pass the EntityManager that dataSource.transaction() hands its callback, and type the outbox with it. The store refuses dataSource.manager, whose writes would commit on their own. This is the order service with TypeORM:

typeorm/orders.service.ts
JS TS

@Injectable()
export class OrdersService {
  constructor(
    private readonly dataSource: DataSource,
    private readonly outbox: Outbox<EntityManager>,
  ) {}

  async placeOrder({ userId, items }: PlaceOrderDto): Promise<Order> {
    if (!items?.length) {
      throw new BadRequestException('An order needs at least one item');
    }

    const order = await this.dataSource.transaction(async (manager) => {
      // ... price the items through manager (an unknown product throws) and build the order
      await manager.insert(OrderEntity, order);

      // The transaction's EntityManager: the message commits or rolls back with the order.
      await this.outbox.add(manager, { topic: 'order.placed', payload: order });
      return order;
    });

    this.outbox.notify(); // publish now instead of at the next poll
    return order;
  }
}

With Prisma. Pass your PrismaService to fromPrisma() in the factory, and to outbox.add() the transaction client that prisma.$transaction() hands its callback. Only that interactive form has one to pass: the array form, prisma.$transaction([query1, query2]), can't include an outbox.add(). The store refuses the client itself, whose statements would commit on their own. Its own transactions wait up to 10 seconds for a connection and run for up to a minute, longer than Prisma's defaults: the second argument of fromPrisma() changes that, with maxWait and timeout.

With MySQL. For MySQL 8.4 LTS and 9.x, @nestjs/outbox/mysql has MySqlOutboxStore. Import the store and its executor from there: the provider is the same, and so are the options and the executors' names. This is the order API on MySQL, with Drizzle on the mysql2 driver:

mysql/app.module.ts
JS TS

import { Module } from '@nestjs/common';
import { DrizzleModule, getDrizzleToken } from '@nestjs/drizzle';
import { OutboxModule, OutboxStorage } from '@nestjs/outbox';
import { fromDrizzle, MySqlOutboxStore } from '@nestjs/outbox/mysql';
import { drizzle } from 'drizzle-orm/mysql2';
import { NotificationsModule } from '../notifications/notifications.module.js';
import type { Database } from './drizzle.js';
import { OrdersService } from './orders.service.js';

@Module({
  imports: [
    DrizzleModule.forRootAsync({
      // A mysql2 pool on DATABASE_URL (mysql://...), whose path names the database
      useFactory: () => ({ drizzle, connection: { uri: process.env.DATABASE_URL! } }),
    }),
    OutboxModule.forRoot({ relay: { pollInterval: '1s' } }),
    NotificationsModule,
  ],
  providers: [
    {
      // Messages and inbox records in your database, in tables of their own (nest_outbox_*)
      provide: MySqlOutboxStore,
      inject: [getDrizzleToken(), OutboxStorage],
      useFactory: (db: Database, outboxStorage: OutboxStorage) =>
        new MySqlOutboxStore({ executor: fromDrizzle(db) }, outboxStorage),
    },
    OrdersService,
  ],
})
export class AppModule {}

fromMysql2(pool), fromTypeOrm(dataSource) for a data source of type mysql, fromPrisma(prisma) with the @prisma/adapter-mariadb driver adapter (the one that serves MySQL), and fromKysely(db) serve the other clients. What differs from PostgreSQL:

  • Tables, not a schema. A MySQL database has no schemas, so the store keeps its tables in the connection's database, the one the URL's path names, and the schema option is their prefix: nest_outbox_messages, nest_outbox_dead_letters and nest_outbox_inbox, next to nest_outbox_migrations and nest_outbox_locks. It takes lowercase letters, digits and underscores, up to 40 characters. A connection without a database fails the startup.
  • Bounded keys. Ids, topics, keys and consumer names are indexed columns of at most 255 characters, compared byte for byte: order-1 and Order-1 are two keys. A longer one is refused with a RangeError, before any statement: by add(), and by the inbox before it runs a handler.
  • Your isolation level. Your transactions may run at REPEATABLE READ, MySQL's default, or at READ COMMITTED. A delivery that races another to processInTransaction() waits for it, then skips the handler as a duplicate, at either level.
  • Deadlocks reach your transaction.add() locks a row per key in your transaction, one of 16,384 that the keys share, which the store creates when it starts: producers of two keys that share a row take turns, as producers of one key do, which slows an unrelated producer only rarely. When MySQL breaks a deadlock in your transaction (error 1213), it rolls the whole transaction back, and the store can't run your work again: the error reaches your code as your client's error, so run the transaction again. With those rows in place, two things can still cause one: three deliveries of one message that race to processInTransaction() while the first rolls back, and transactions of yours that take the same locks in different orders. The store's own transactions retry on deadlock by themselves.
  • Migrations that aren't transactional. MySQL commits each DDL statement on its own, so migrate and npx nest-outbox migrate --url mysql://... apply the statements one at a time under a lock, and a run that stopped halfway resumes where it stopped. Your own tool must send one statement per call too, as the TypeORM migration in Migrations above does, and a drizzle-kit custom migration takes npx nest-outbox sql --dialect mysql --statement-breakpoints, since its MySQL migrator sends one statement per breakpoint.

Register the outbox#

Register OutboxModule in the root module, next to DrizzleModule, which registers the Drizzle database, and with the store from Keep messages in your database. The feature modules it imports are built in the next steps:

app.module.ts
JS TS

import { Module } from '@nestjs/common';
import { DrizzleModule, getDrizzleToken } from '@nestjs/drizzle';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { ClientProxyTransport, OutboxModule, OutboxStorage } from '@nestjs/outbox';
import { fromDrizzle, PostgresOutboxStore } from '@nestjs/outbox/postgres';
import { drizzle } from 'drizzle-orm/node-postgres';
import type { Database } from './database/drizzle.js';
import * as schema from './database/schema.js';
import { InventoryModule } from './inventory/inventory.module.js';
import { NotificationsModule } from './notifications/notifications.module.js';
import { OrdersModule } from './orders/orders.module.js';
import { OutboxAdminModule } from './outbox-admin/outbox-admin.module.js';

export const ANALYTICS_SERVICE = 'ANALYTICS_SERVICE';

@Module({
  imports: [
    DrizzleModule.forRootAsync({
      // A pg pool on DATABASE_URL, closed in onApplicationShutdown(), after the relay drained.
      useFactory: () => ({ drizzle, connection: process.env.DATABASE_URL!, schema }),
    }),
    OutboxModule.forRootAsync({
      imports: [
        ClientsModule.registerAsync([
          {
            name: ANALYTICS_SERVICE,
            useFactory: () => ({
              transport: Transport.TCP,
              options: {
                host: process.env.ANALYTICS_HOST ?? '127.0.0.1',
                port: Number(process.env.ANALYTICS_PORT ?? 4001),
              },
            }),
          },
        ]),
      ],
      transports: { analytics: ClientProxyTransport(ANALYTICS_SERVICE) },
      useFactory: () => ({
        // Each message goes to exactly one transport; `local` runs @OnOutboxMessage() handlers.
        route: (message) => (message.topic.startsWith('analytics.') ? 'analytics' : 'local'),
        relay: {
          enabled: process.env.OUTBOX_RELAY !== 'off',
          pollInterval: '1s',
          lease: '30s',
          publishTimeout: '10s',
        },
        retry: {
          attempts: 10,
          backoff: { delay: '1s', maxDelay: '1m' },
        },
      }),
    }),
    OrdersModule,
    NotificationsModule,
    InventoryModule,
    OutboxAdminModule,
  ],
  providers: [
    {
      // Messages and inbox records in your database, in a schema of their own (nest_outbox)
      provide: PostgresOutboxStore,
      inject: [getDrizzleToken(), OutboxStorage],
      useFactory: (db: Database, outboxStorage: OutboxStorage) =>
        new PostgresOutboxStore({ executor: fromDrizzle(db) }, outboxStorage),
    },
  ],
})
export class AppModule {}

ClientProxyTransport(ANALYTICS_SERVICE) is a class that Nest instantiates inside OutboxModule, so transports sits next to useFactory, where classes go. The factory runs when the application starts and returns the rest of the options:

  • transports and route: where each message is published. The local transport is built in and runs @OnOutboxMessage() handlers in this process (Handle order.placed in-process). ClientProxyTransport(ANALYTICS_SERVICE) publishes through the TCP client that ClientsModule registers under that name, which is why that module is in imports (Publish to the analytics microservice). route picks exactly one transport per message.
  • relay: the background publisher. It polls every pollInterval, leases what it claims for lease, and gives up on a single publish after publishTimeout. Setting OUTBOX_RELAY=off turns it off in API-only instances (Run several instances).
  • retry: 10 attempts, with exponential backoff that starts at 1 second and is capped at 1 minute (Retries and the dead-letter queue).
Hint The tutorial reads process.env directly, to stay short. In an application, load the environment through @nestjs/config with a validation schema, so a missing DATABASE_URL stops the application at startup, and read the values from ConfigService in the factories.

Durations are milliseconds or strings such as '30s' and '1m'. OutboxModule is global: Outbox, OutboxStorage, OutboxRelay, OutboxEvents, OutboxDeadLetters and OutboxInbox can be injected anywhere in the application.

Save the order and its messages in one transaction#

This is the order model:

orders/order.ts
JS TS

export interface OrderItem {
  productId: string;
  quantity: number;
  /** Unit price in cents, copied from the catalog when the order is placed. */
  price: number;
}

export interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  /** In cents. */
  total: number;
  status: 'placed' | 'cancelled';
}

export class PlaceOrderDto {
  userId: string;
  items: { productId: string; quantity: number }[];
}

OrdersService.placeOrder() opens a Drizzle transaction, prices the items, inserts the order and adds the messages, all through the same tx:

orders/orders.service.ts
JS TS

import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDrizzle } from '@nestjs/drizzle';
import { Outbox } from '@nestjs/outbox';
import type { Database, Transaction } from '../database/drizzle.js';
import { orders, products } from '../database/schema.js';
import type { Order, PlaceOrderDto } from './order.js';

@Injectable()
export class OrdersService {
  constructor(
    @InjectDrizzle() private readonly db: Database,
    private readonly outbox: Outbox<Transaction>,
  ) {}

  async placeOrder({ userId, items }: PlaceOrderDto): Promise<Order> {
    if (!items?.length) {
      throw new BadRequestException('An order needs at least one item');
    }

    const order = await this.db.transaction(async (tx) => {
      // ... price the items through tx (an unknown product throws) and build the order
      await tx.insert(orders).values(order);

      // Drizzle's tx: the messages commit or roll back with the order.
      await this.outbox.add(tx, [
        { topic: 'order.placed', payload: order },
        { topic: 'analytics.order.placed', key: order.id, payload: order },
      ]);
      return order;
    });

    this.outbox.notify(); // publish now instead of at the next poll
    return order;
  }
}

outbox.add() takes the transaction handle first, then the message or an array of messages. With Drizzle, the handle is the tx that db.transaction() passes its callback, whose type the service gives the outbox: Outbox<Transaction>. The store inserts them through that tx, so the messages commit with the order, or roll back with it. The store is asynchronous, so await add() like any other query. Every API in the package that joins your transaction takes the handle as its first argument.

A few details:

  • One message per destination. Each message goes to exactly one transport. The same event goes to the in-process handlers and to the analytics service, so the service adds two messages, order.placed and analytics.order.placed, in the same transaction.
  • Payloads are snapshots.add() serializes the payload right away, so later changes to the order object aren't published.
  • Ids. Every message gets a time-ordered UUIDv7 id. Consumers deduplicate by it.
  • notify(). Nothing is published inside the transaction. After the commit, notify() wakes the relay. Without it, the message goes out at the next poll, up to pollInterval later.

If anything throws inside the transaction, the order and its messages roll back together, and the relay never sees them. That includes an unknown product, a failed insert, and anything that fails after add(). The Testing section proves it by failing the transaction after the messages were written.

Pass the transaction, never the database: outbox.add(this.db, message) would write the message on its own, in autocommit mode, which is exactly the dual write you're removing. The store refuses it with OutboxTransactionRequiredError, and so does add() itself when it gets no handle at all.

Expose the service over HTTP:

orders/orders.controller.ts
JS TS

import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { PlaceOrderDto } from './order.js';
import { OrdersService } from './orders.service.js';

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  place(@Body() dto: PlaceOrderDto) {
    return this.ordersService.placeOrder(dto);
  }

  @Post(':id/cancel')
  @HttpCode(HttpStatus.OK)
  cancel(@Param('id') id: string) {
    return this.ordersService.cancelOrder(id);
  }
}

Handle order.placed in-process#

The confirmation email is a handler for order.placed. The mailer is a stand-in for your email provider's SDK:

notifications/mailer.service.ts
JS TS

import { Injectable, Logger } from '@nestjs/common';
import type { Order } from '../orders/order.js';

/** Stand-in for your email provider's SDK. */
@Injectable()
export class MailerService {
  private readonly logger = new Logger(MailerService.name);

  async sendOrderConfirmation(order: Order): Promise<void> {
    this.logger.log(`Order confirmation for ${order.id} sent to ${order.userId}`);
  }
}
notifications/order-emails.handler.ts
JS TS

import { Injectable } from '@nestjs/common';
import { OnOutboxMessage } from '@nestjs/outbox';
import type { Order } from '../orders/order.js';
import { MailerService } from './mailer.service.js';

@Injectable()
export class OrderEmailsHandler {
  constructor(private readonly mailerService: MailerService) {}

  // The consumer's inbox skips messages it has already handled.
  @OnOutboxMessage('order.placed', { consumer: 'order-confirmation-email' })
  async sendConfirmation(order: Order) {
    await this.mailerService.sendOrderConfirmation(order);
  }
}

@OnOutboxMessage() handlers are discovered on providers, so register MailerService and OrderEmailsHandler as providers of a NotificationsModule. They must be singletons: a handler on a request-scoped provider fails at startup. The handler gets the payload, here the order, and a context as its second argument. The context's message holds the message's id, topic, key and headers.

Every handler has an inbox, named by consumer. Before calling the handler, the local transport checks whether the order-confirmation-email consumer has already processed this message id. After the handler succeeds, it records the id. consumer is required, and it must stay stable: a new name makes every past message look new.

The inbox has a gap: if the process dies after the email is sent but before the id is recorded, the email is sent again on redelivery. That's acceptable for an email. It isn't for a stock reservation, which lives in the same database as the inbox. The reservation handler closes the gap by recording the id inside its own transaction:

inventory/stock-reservation.handler.ts
JS TS

import { Injectable, Logger } from '@nestjs/common';
import { InjectDrizzle } from '@nestjs/drizzle';
import { NonRetryableMessageError, OnOutboxMessage, type OutboxHandlerContext } from '@nestjs/outbox';
import { and, eq, gte, sql } from 'drizzle-orm';
import type { Database, Transaction } from '../database/drizzle.js';
import { products } from '../database/schema.js';
import type { Order } from '../orders/order.js';

@Injectable()
export class StockReservationHandler {
  private readonly logger = new Logger(StockReservationHandler.name);

  constructor(@InjectDrizzle() private readonly db: Database) {}

  @OnOutboxMessage('order.placed', { consumer: 'stock-reservation' })
  async reserve(order: Order, ctx: OutboxHandlerContext<Transaction>) {
    await this.db.transaction(async (tx) => {
      // Records the message id through tx, then runs the callback only if this consumer
      // hasn't processed it yet. Await it: the record commits with the reservation.
      await ctx.processInTransaction(tx, async () => {
        for (const { productId, quantity } of order.items) {
          const reserved = await tx
            .update(products)
            .set({ inStock: sql`${products.inStock} - ${quantity}`, reserved: sql`${products.reserved} + ${quantity}` })
            .where(and(eq(products.id, productId), gte(products.inStock, quantity)))
            .returning({ id: products.id });
          if (reserved.length === 0) {
            // Retrying won't create stock: dead-letter it now and let a human decide.
            throw new NonRetryableMessageError(`Not enough stock for "${productId}" (order ${order.id})`);
          }
        }
        this.logger.log(`Reserved stock for order ${order.id}`);
      });
    });
  }
}

ctx.processInTransaction(tx, work) inserts the inbox record through the handler's transaction, then calls work only if this consumer hasn't processed the message yet. Otherwise it skips work and returns a result whose duplicate flag is true. The record and the stock update commit together, so the reservation happens exactly once: a redelivery finds the record and skips the handler, and if work throws, both roll back and the retry starts clean. Await it inside the transaction's callback, so the record is written before COMMIT; the duplicate check happens inside the call, so a missing await can't make it pass. Register the handler in an InventoryModule.

HandlerBehaviorGuarantee
DefaultSkips the handler if its inbox has the id, records the id after the handler succeedsOnce, unless the process dies between the handler finishing and the record, or another instance delivers the same message at the same time
Calls ctx.processInTransaction(tx, work)Records the id in the handler's own transaction, with its writesExactly once for writes to the same database
inbox: falseEvery delivery runs the handlerAt least once

Both handlers run concurrently for each order.placed message. If one of them fails, the publish fails and the whole message is retried. On the retry, each handler that already succeeded is skipped by its inbox.

The relay gives the handlers publishTimeout (10 seconds here). If they take longer, the attempt counts as failed and the context's signal aborts: pass ctx.signal to the calls a handler makes, such as the signal option of fetch(), so they stop too. A handler that ignores it keeps running, and if the retry reaches the same process while it does, the inbox waits for it instead of running the handler a second time.

Publish to the analytics microservice#

The API side is already wired. In AppModule, ClientProxyTransport(ANALYTICS_SERVICE) publishes through the ClientProxy that ClientsModule registered under that name, and route sends every analytics.* topic to it. The transport calls client.emit(topic, envelope), the same call you'd make without an outbox. The envelope has the message's id, topic, key, headers, createdAt and payload.

Cancelling an order produces a second analytics event for the same order. Add a cancelOrder() method to OrdersService, and import NotFoundException and ConflictException from @nestjs/common:

orders/orders.service.ts
JS TS

async cancelOrder(id: string): Promise<Order> {
  const order = await this.db.transaction(async (tx) => {
    const [row] = await tx.select().from(orders).where(eq(orders.id, id)).for('update');
    if (!row) {
      throw new NotFoundException(`Order ${id} not found`);
    }
    if (row.status !== 'placed') {
      throw new ConflictException(`Order ${id} is ${row.status}`);
    }

    await tx.update(orders).set({ status: 'cancelled' }).where(eq(orders.id, id));
    const order: Order = { ...row, status: 'cancelled' };
    await this.outbox.add(tx, { topic: 'analytics.order.cancelled', key: order.id, payload: order });
    return order;
  });

  this.outbox.notify();
  return order;
}

The orders controller already routes POST /orders/:id/cancel to it. The SELECT ... FOR UPDATE locks the order's row, so two cancellations of the same order take turns, and the second one sees cancelled and answers 409. Both analytics messages use the order id as their key. Messages that share a key are published one at a time, in the order their transactions committed. While analytics.order.placed is being retried, analytics.order.cancelled waits behind it, so analytics doesn't see a cancellation before the order it cancels. Messages with other keys keep flowing. A key waits only as long as the retries last, though: once a message is dead-lettered (see the next section), the messages behind it go out.

The order.placed message for the in-process handlers has no key, on purpose. A key is shared by every message that carries it, whatever the topic or transport. If the email message used the order id as well, a mail outage would hold back the analytics events for that order.

The analytics service is a separate Nest application, and like any service it owns its data: a PostgreSQL database of its own, with its own schema and migrations. It never reads the order API's tables. It records each order event in a table of its own, again the example's data:

order_events has one row per order event, and the revenue is the sum of its amount column:

ColumnTypeNotes
seqbigint, generated identityPrimary key, in arrival order
order_idtextIndexed
eventtextplaced or cancelled
amountintegerThe change to revenue, in cents: the order's total when it's placed, minus it when it's cancelled
recorded_attimestamptzDefaults to now()

Being an application of its own, it has its own root module, AppModule, which registers OutboxModule and the store once, as the order API's does. The service only consumes, so the relay is turned off, and the store runs on the service's own database. OutboxInbox keeps the service's inbox there, next to order_events, so an event and the record that it was processed commit together. The store's tables for messages stay empty, and with the relay off, nothing polls them:

analytics-service/app.module.ts
JS TS

import { Module } from '@nestjs/common';
import { DrizzleModule, getDrizzleToken } from '@nestjs/drizzle';
import { OutboxModule, OutboxStorage } from '@nestjs/outbox';
import { fromDrizzle, PostgresOutboxStore } from '@nestjs/outbox/postgres';
import { drizzle } from 'drizzle-orm/node-postgres';
import { AnalyticsController } from './analytics.controller.js';
import type { Database } from './database/drizzle.js';
import * as schema from './database/schema.js';
import { OrderStatsService } from './order-stats.service.js';

@Module({
  imports: [
    DrizzleModule.forRootAsync({
      // This service's own database, not the order API's.
      useFactory: () => ({ drizzle, connection: process.env.DATABASE_URL!, schema }),
    }),
    // Consumer only: no relay runs.
    OutboxModule.forRoot({ relay: { enabled: false } }),
  ],
  controllers: [AnalyticsController],
  providers: [
    {
      // This service's inbox, in its own database (the store's tables for messages stay empty)
      provide: PostgresOutboxStore,
      inject: [getDrizzleToken(), OutboxStorage],
      useFactory: (db: Database, outboxStorage: OutboxStorage) =>
        new PostgresOutboxStore({ executor: fromDrizzle(db) }, outboxStorage),
    },
    OrderStatsService,
  ],
})
export class AppModule {}

OrderStatsService records an event and returns the revenue so far. It opens a transaction and calls inbox.processInTransaction(tx, 'analytics', messageId, work): the inbox record and the order_events row commit together, so a redelivered event changes nothing, even if the service crashes halfway:

analytics-service/order-stats.service.ts
JS TS

import { Injectable, Logger } from '@nestjs/common';
import { InjectDrizzle } from '@nestjs/drizzle';
import { OutboxInbox } from '@nestjs/outbox';
import type { Database, Transaction } from './database/drizzle.js';
import { orderEvents } from './database/schema.js';

/** The fields of the order API's `Order` that this service reads. */
export interface OrderSnapshot {
  id: string;
  total: number;
}

export type OrderEvent = 'placed' | 'cancelled';

@Injectable()
export class OrderStatsService {
  private readonly logger = new Logger(OrderStatsService.name);

  constructor(
    @InjectDrizzle() private readonly db: Database,
    private readonly outboxInbox: OutboxInbox,
  ) {}

  /**
   * Records an order event once per message id. The inbox record and the event commit in
   * one transaction, so a redelivered message changes nothing. `false` for a duplicate.
   */
  async record(messageId: string, event: OrderEvent, order: OrderSnapshot): Promise<boolean> {
    const outcome = await this.db.transaction(async (tx) =>
      // Records the message id through tx, then runs the callback only if it is new here.
      this.outboxInbox.processInTransaction(tx, 'analytics', messageId, async () => {
        const amount = event === 'placed' ? order.total : -order.total;
        await tx.insert(orderEvents).values({ orderId: order.id, event, amount });
        return this.revenue(tx);
      }),
    );
    if (outcome.duplicate) {
      return false;
    }
    this.logger.log(`Order ${order.id} ${event}, revenue is now ${outcome.result}`);
    return true;
  }

  /** Total revenue in cents. */
  async revenue(db: Database | Transaction = this.db): Promise<number> {
    // ... the sum of amount in order_events, read through db
  }

  // ...
}
analytics-service/analytics.controller.ts
JS TS

import { Controller, Logger } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import type { OutboxEnvelope } from '@nestjs/outbox';
import { OrderStatsService, type OrderEvent, type OrderSnapshot } from './order-stats.service.js';

@Controller()
export class AnalyticsController {
  private readonly logger = new Logger(AnalyticsController.name);
  /** The last event handled for each key: one order's events run one at a time. */
  private readonly queues = new Map<string, Promise<void>>();

  constructor(private readonly orderStatsService: OrderStatsService) {}

  @EventPattern('analytics.order.placed')
  onPlaced(@Payload() envelope: OutboxEnvelope<OrderSnapshot>) {
    return this.apply('placed', envelope);
  }

  @EventPattern('analytics.order.cancelled')
  onCancelled(@Payload() envelope: OutboxEnvelope<OrderSnapshot>) {
    return this.apply('cancelled', envelope);
  }

  private apply(event: OrderEvent, envelope: OutboxEnvelope<OrderSnapshot>) {
    // Events arrive in the order the relay published them, but handlers are asynchronous:
    // without a queue, a cancellation could commit before the placement it follows.
    return this.inOrder(envelope.key ?? envelope.id, async () => {
      // The envelope id is the outbox message id: stable across redeliveries.
      const recorded = await this.orderStatsService.record(envelope.id, event, envelope.payload);
      if (!recorded) {
        this.logger.warn(`Skipped duplicate ${envelope.topic} ${envelope.id}`);
      }
    });
  }

  private inOrder(key: string, work: () => Promise<void>): Promise<void> {
    const next = (this.queues.get(key) ?? Promise.resolve()).then(work, work);
    this.queues.set(key, next);
    const forget = () => {
      if (this.queues.get(key) === next) {
        this.queues.delete(key);
      }
    };
    next.then(forget, forget);
    return next;
  }
}

The events for an order arrive in the order the relay published them, but the handlers are asynchronous: two of them can run at once, and a cancellation could commit before the placement it follows. The controller runs one key's events one at a time, in arrival order, and different orders in parallel.

analytics-service/main.ts
JS TS

import { NestFactory } from '@nestjs/core';
import { Transport, type MicroserviceOptions } from '@nestjs/microservices';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.TCP,
    options: { host: '127.0.0.1', port: Number(process.env.PORT ?? 4001) },
  });
  app.enableShutdownHooks();
  await app.listen();
}
await bootstrap();

inbox.processInTransaction() is what an @OnOutboxMessage() handler gets with ctx.processInTransaction(): exactly once for writes to the consumer's own database. When a consumer's side effect is outside its database, such as an email, use inbox.process(consumer, id, work) instead: it skips work if the consumer has seen this id, and records it after work succeeds.

Warning Over TCP, "published" only means the bytes left the order API. If the analytics service crashes after receiving an event but before processing it, the event is lost. For events you can't lose, use a transport whose broker acknowledges receipt: Kafka, RabbitMQ with publisher confirms, or NATS JetStream. ClientProxyTransport accepts a toPacket option that maps a message to that transport's record, with its own key and headers.

Retries and the dead-letter queue#

When a publish fails, because a handler threw, the TCP connection was refused, or the publish took longer than publishTimeout, the relay reschedules the message:

  • Each retry waits longer: 1 s, 2 s, 4 s and so on, capped at maxDelay (1 minute here). Each wait is randomized between half and the full value, so retries don't arrive in lockstep.
  • Each failure is logged as a warning, with the attempt number and the wait before the next one.
  • After retry.attempts failures (10 here), the message moves to the dead-letter table with the reason exhausted and the error of every attempt.
  • A NonRetryableMessageError skips the retries. The message is dead-lettered at once with the reason rejected. So is any error for which the optional retry.retryIf(error, attempt, message) function returns false. When several handlers fail, the message is rejected only if all of them threw NonRetryableMessageError. Otherwise it's retried, the handlers that succeeded are skipped, and it's rejected once only the permanent failures remain.
  • A topic with no @OnOutboxMessage() handler is retried, not dead-lettered. During a rolling deploy, an old instance's relay can see a topic that only the new version handles.
  • A dead-lettered message no longer holds back its key: the later messages with that key are published without it. Size retry so that the outages you expect don't use it up.

The stock handler throws NonRetryableMessageError when there isn't enough stock. Retrying won't create stock, so the message goes straight to the dead-letter table, where a person can decide what to do.

OutboxDeadLetters lists, inspects, requeues and purges dead letters. The package doesn't mount any HTTP routes, so expose it behind your own guard. The same controller serves the outbox's stats, from the OutboxMetrics service built in the next section:

outbox-admin/outbox-admin.controller.ts
JS TS

import {
  Controller,
  Delete,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Post,
  Query,
  UseGuards,
} from '@nestjs/common';
import { OutboxDeadLetters } from '@nestjs/outbox';
import { AdminGuard } from './admin.guard.js';
import { OutboxMetrics } from './outbox-metrics.service.js';

@Controller('admin/outbox')
@UseGuards(AdminGuard)
export class OutboxAdminController {
  constructor(
    private readonly outboxDeadLetters: OutboxDeadLetters,
    private readonly outboxMetrics: OutboxMetrics,
  ) {}

  @Get('dead-letters')
  list(@Query('topic') topic?: string) {
    return this.outboxDeadLetters.list({ topic });
  }

  @Post('dead-letters/:id/requeue')
  @HttpCode(HttpStatus.OK)
  async requeue(@Param('id') id: string) {
    return { requeued: await this.outboxDeadLetters.requeue(id) };
  }

  @Delete('dead-letters/:id')
  async purge(@Param('id') id: string) {
    return { purged: await this.outboxDeadLetters.purge(id) };
  }

  @Get('stats')
  stats() {
    return this.outboxMetrics.snapshot();
  }
}
outbox-admin/admin.guard.ts
JS TS

import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { createHash, timingSafeEqual } from 'node:crypto';

type HttpRequest = { headers: Record<string, string | string[] | undefined> };

/** Placeholder: protect these routes with your application's real authorization. */
@Injectable()
export class AdminGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<HttpRequest>();
    const token = process.env.ADMIN_TOKEN;
    const given = request.headers['x-admin-token'];
    return !!token && typeof given === 'string' && sameSecret(given, token);
  }
}

/** Compares in constant time, so response times don't give the token away. */
function sameSecret(given: string, expected: string): boolean {
  const digest = (value: string) => createHash('sha256').update(value).digest();
  return timingSafeEqual(digest(given), digest(expected));
}
HintAdminGuard is a placeholder. Dead letters contain full payloads, customer data included, so protect these routes with your application's real authorization.

requeue() moves a dead letter back to the outbox with a fresh retry budget and the same id. Consumers that already processed it skip it through their inbox. So after you restock, requeueing the out-of-stock order reserves the stock without sending a second confirmation email. requeue() and purge() also accept an array of ids, or a filter by topic, key or failedBefore. They refuse an empty filter; pass all: true to target every dead letter.

Run several instances#

Every instance of the order API runs its relay against the same tables, on any number of hosts. The database decides who gets what:

  • Claims. A relay claims a batch of messages by writing a lease on them for lease (30 s), with a random token for that claim. Claims take turns under the store's claim lock, and SKIP LOCKED passes over rows another relay is writing, so two relays never get the same message. They publish their batches in parallel.
  • Fencing. Every later write for a message (published, rescheduled, dead-lettered) must present the lease token, so a relay that stalled past its lease can't overwrite the work of the relay that took over.
  • Order across instances. A key's messages go out in the order their transactions committed, whichever instance added them: add() makes the transactions adding the same key take turns. Keep transactions that add keyed messages short.
  • Exactly-once reservations. Two deliveries of the same message that run at the same time, on different instances, meet at the inbox's primary key: the second waits for the first transaction, then skips the handler if it committed.
  • Crashes. If an instance dies while holding a lease, its messages are published by another instance once the lease expires. If it died after publishing but before recording that, the message is published a second time, and the consumers' inboxes drop the duplicate. Nothing is lost with the instance, because nothing lives on it: the messages are rows in the database.
  • Lease budget. A relay never starts a publish with less than publishTimeout (10 s) of lease left. It releases the rest of the batch instead. Keep publishTimeout well below lease.
  • Clocks. Leases use the relay's clock, so keep the hosts' clocks synchronized with NTP, well within lease.
  • API-only instances. Start an instance with OUTBOX_RELAY=off to make it produce messages without publishing them. notify() wakes only the relay of the instance that added the message, so a relay instance picks the messages up within one pollInterval.
  • Relay-only workers. The reverse also works: a worker started with NestFactory.createApplicationContext(AppModule) and no HTTP server publishes what the API instances add. The relay's poll timer keeps that process alive until a shutdown hook stops it.

Graceful shutdown needs shutdown hooks:

main.ts
JS TS

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // On SIGTERM the relay stops claiming, finishes in-flight publishes and releases its leases.
  app.enableShutdownHooks();
  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();

On SIGTERM, the relay stops claiming and waits for in-flight publishes to finish, each for at most publishTimeout. It then releases the leases on messages it claimed but hasn't started, so another instance can take them at once instead of waiting out the lease. This happens in onModuleDestroy(), before ClientsModule closes the TCP client in onApplicationShutdown().

To watch the outbox, subscribe to OutboxEvents and read relay.stats():

outbox-admin/outbox-metrics.service.ts
JS TS

import { Injectable, type OnModuleInit } from '@nestjs/common';
import { OutboxEvents, OutboxRelay } from '@nestjs/outbox';

@Injectable()
export class OutboxMetrics implements OnModuleInit {
  readonly counters = { published: 0, retried: 0, deadLettered: 0, leaseLost: 0 };
  /** Time from add() to a successful publish, for the latest message. */
  lastPublishLagMs = 0;

  constructor(
    private readonly outboxEvents: OutboxEvents,
    private readonly outboxRelay: OutboxRelay,
  ) {}

  onModuleInit() {
    this.outboxEvents.events$.subscribe((event) => {
      switch (event.type) {
        case 'published':
          this.counters.published++;
          this.lastPublishLagMs = Date.now() - event.message.createdAt;
          break;
        case 'retry-scheduled':
          this.counters.retried++;
          break;
        case 'dead-lettered':
          this.counters.deadLettered++;
          break;
        case 'lease-lost':
          this.counters.leaseLost++;
          break;
      }
    });
  }

  async snapshot() {
    // pending, ready, leased, deadLetters, lagMs (how long the longest-waiting message has waited)...
    const stats = await this.outboxRelay.stats();
    return { ...stats, ...this.counters, lastPublishLagMs: this.lastPublishLagMs };
  }
}

relay.stats() reports lagMs, how long the longest-waiting message has waited. It grows while a downstream is down, which makes it the number to alert on, along with deadLetters. A message scheduled with delay doesn't count until it is due. The published event carries the message, so Date.now() - message.createdAt is the end-to-end publish lag. OutboxEvents emits the four events the switch counts. lease-lost means another relay took a message over while this one was publishing it, so that message may reach its consumers twice. Register OutboxAdminController, AdminGuard and OutboxMetrics in an OutboxAdminModule.

Each event is also published on a node:diagnostics_channel channel, nestjs:outbox:published and so on. Tracing and metrics libraries can subscribe to those without any Nest code, and they see every Nest application in the process.

The example application runs this section against a PostgreSQL server: two relays sharing 20 orders, three instances (one of them API-only) placing and cancelling 12 orders with every message published once and each order's events in order, a transaction that waits for another instance's lock on the same key, an instance that crashes holding a lease, and a graceful shutdown.

Try it#

Create the two databases, store for the order API and analytics for the analytics service, and apply each service's own migrations: the order API's create its tables and products from Prerequisites, and the analytics service's create order_events.

Start the analytics service (TCP port 4001) on analytics, and the order API (port 3000) on store, with ADMIN_TOKEN=s3cret in the API's environment. Each logs the store it registered, and the store creates its schema on the first start:


[Nest] 52654  - 09/30/2026, 6:45:43 PM     LOG [OutboxModule] OutboxStorage: PostgresOutboxStore
[Nest] 52654  - 09/30/2026, 6:45:43 PM     LOG [OutboxModule] PostgresOutboxStore: migrated schema "nest_outbox" to version 1.
[Nest] 52743  - 09/30/2026, 6:45:59 PM     LOG [OutboxModule] OutboxStorage: PostgresOutboxStore
[Nest] 52743  - 09/30/2026, 6:46:00 PM     LOG [OutboxModule] PostgresOutboxStore: migrated schema "nest_outbox" to version 1.

Place an order:


$ curl -X POST localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -d '{"userId":"user-42","items":[{"productId":"salmon-kibble-2kg","quantity":2}]}'
{"id":"07fafff6-9892-4298-aeef-dc2c3d5d6bf5","userId":"user-42","items":[{"productId":"salmon-kibble-2kg","quantity":2,"price":2499}],"total":4998,"status":"placed"}

The API's log shows the two in-process handlers:


[Nest] 52743  - 09/30/2026, 6:46:07 PM     LOG [MailerService] Order confirmation for 07fafff6-9892-4298-aeef-dc2c3d5d6bf5 sent to user-42
[Nest] 52743  - 09/30/2026, 6:46:07 PM     LOG [StockReservationHandler] Reserved stock for order 07fafff6-9892-4298-aeef-dc2c3d5d6bf5

And the analytics service's log shows the TCP consumer:


[Nest] 52654  - 09/30/2026, 6:46:07 PM     LOG [OrderStatsService] Order 07fafff6-9892-4298-aeef-dc2c3d5d6bf5 placed, revenue is now 4998

An order for an unknown product fails validation inside the transaction. The response is a 400, and nothing reaches the outbox:


$ curl -X POST localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -d '{"userId":"user-42","items":[{"productId":"catnip-mouse","quantity":1}]}'
{"message":"Unknown product \"catnip-mouse\"","error":"Bad Request","statusCode":400}

Now order two bags of clumping litter (clumping-litter-10l), which has one in stock. The API accepts the order, because stock is reserved asynchronously. The confirmation email goes out, and the reservation is dead-lettered on its first attempt:


$ curl -X POST localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -d '{"userId":"user-7","items":[{"productId":"clumping-litter-10l","quantity":2}]}'
{"id":"bf4b8bb3-fd47-4c03-9650-ee991cce3dbe","userId":"user-7","items":[{"productId":"clumping-litter-10l","quantity":2,"price":1599}],"total":3198,"status":"placed"}

[Nest] 52743  - 09/30/2026, 6:46:15 PM     LOG [MailerService] Order confirmation for bf4b8bb3-fd47-4c03-9650-ee991cce3dbe sent to user-7
[Nest] 52743  - 09/30/2026, 6:46:15 PM    WARN [OutboxRelay] Dead-lettered order.placed 01a0f335-aae8-7027-b795-ca9dab21e44d after 1 attempt(s) (rejected): NonRetryableMessageError: Not enough stock for "clumping-litter-10l" (order bf4b8bb3-fd47-4c03-9650-ee991cce3dbe)

Inspect the dead-letter queue. Without the x-admin-token header, the guard answers 403:


$ curl localhost:3000/admin/outbox/dead-letters -H 'x-admin-token: s3cret'

[
  {
    "id": "01a0f335-aae8-7027-b795-ca9dab21e44d",
    "topic": "order.placed",
    "payload": {
      "id": "bf4b8bb3-fd47-4c03-9650-ee991cce3dbe",
      "items": [{ "price": 1599, "quantity": 2, "productId": "clumping-litter-10l" }],
      "total": 3198,
      "status": "placed",
      "userId": "user-7"
    },
    "headers": {},
    "key": null,
    "createdAt": 1790786775784,
    "attempts": 1,
    "lastError": "NonRetryableMessageError: Not enough stock for \"clumping-litter-10l\" (order bf4b8bb3-fd47-4c03-9650-ee991cce3dbe)",
    "history": [
      {
        "at": 1790786775809,
        "error": "NonRetryableMessageError: Not enough stock for \"clumping-litter-10l\" (order bf4b8bb3-fd47-4c03-9650-ee991cce3dbe)",
        "attempt": 1,
        "transport": "local"
      }
    ],
    "reason": "rejected",
    "failedAt": 1790786775809
  }
]

The payload is stored as jsonb, which keeps its own key order, so it comes back with its keys in a different order than they were added.

Restock, then requeue the message by its id:


$ psql postgres://localhost:5432/store -c "UPDATE products SET in_stock = in_stock + 5 WHERE id = 'clumping-litter-10l'"
UPDATE 1
$ curl -X POST localhost:3000/admin/outbox/dead-letters/01a0f335-aae8-7027-b795-ca9dab21e44d/requeue \
    -H 'x-admin-token: s3cret'
{"requeued":1}

The reservation goes through. The email handler's inbox already has this message id, so no second email is sent:


[Nest] 52743  - 09/30/2026, 6:46:24 PM     LOG [StockReservationHandler] Reserved stock for order bf4b8bb3-fd47-4c03-9650-ee991cce3dbe

Finally, stop the analytics service with Ctrl+C, then place an order and cancel it:


$ curl -X POST localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -d '{"userId":"user-42","items":[{"productId":"salmon-kibble-2kg","quantity":1}]}'
{"id":"908139a7-2bab-4dd8-988a-bdde8c087074","userId":"user-42","items":[{"productId":"salmon-kibble-2kg","quantity":1,"price":2499}],"total":2499,"status":"placed"}
$ curl -X POST localhost:3000/orders/908139a7-2bab-4dd8-988a-bdde8c087074/cancel
{"id":"908139a7-2bab-4dd8-988a-bdde8c087074","userId":"user-42","items":[{"price":2499,"quantity":1,"productId":"salmon-kibble-2kg"}],"total":2499,"status":"cancelled"}
$ curl localhost:3000/admin/outbox/stats -H 'x-admin-token: s3cret'
{"pending":2,"ready":0,"leased":0,"deadLetters":0,"oldestDueAt":1790786821554,"lagMs":3351,"inFlight":0,"published":5,"retried":3,"deadLettered":1,"leaseLost":0,"lastPublishLagMs":62}

Both analytics messages are pending. lagMs grows while the outage lasts, and retried counts the failed attempts. ready is 0: the placement is waiting for its next retry, and the cancellation is due but waits behind it, because they share a key. The API logs each failed attempt:


[Nest] 52743  - 09/30/2026, 6:47:01 PM    WARN [OutboxRelay] Retrying analytics.order.placed 01a0f336-5db2-72ad-80c8-17c07908dfa1 in 546ms (attempt 1 of 10 failed): Error: connect ECONNREFUSED 127.0.0.1:4001
[Nest] 52743  - 09/30/2026, 6:47:02 PM    WARN [OutboxRelay] Retrying analytics.order.placed 01a0f336-5db2-72ad-80c8-17c07908dfa1 in 1617ms (attempt 2 of 10 failed): Error: connect ECONNREFUSED 127.0.0.1:4001
[Nest] 52743  - 09/30/2026, 6:47:04 PM    WARN [OutboxRelay] Retrying analytics.order.placed 01a0f336-5db2-72ad-80c8-17c07908dfa1 in 2084ms (attempt 3 of 10 failed): Error: connect ECONNREFUSED 127.0.0.1:4001

Start the analytics service again. At its next retry, the relay publishes the placement, then the cancellation. The revenue picks up where it was, because it lives in the service's database, not in its memory:


[Nest] 53368  - 09/30/2026, 6:47:29 PM     LOG [OrderStatsService] Order 908139a7-2bab-4dd8-988a-bdde8c087074 placed, revenue is now 10695
[Nest] 53368  - 09/30/2026, 6:47:29 PM     LOG [OrderStatsService] Order 908139a7-2bab-4dd8-988a-bdde8c087074 cancelled, revenue is now 8196

Testing#

In an e2e test, run PostgreSQL in-process with PGlite and your real migrations, turn the poll loop off, and drive the relay yourself. relay.runOnce() claims and processes one batch, and resolves when the batch is done:

test/orders.e2e-spec.ts
JS TS

import { PGlite } from '@electric-sql/pglite';
import { getDrizzleToken } from '@nestjs/drizzle';
import { Outbox, OutboxRelay } from '@nestjs/outbox';
import { Test, type TestingModule } from '@nestjs/testing';
import { drizzle } from 'drizzle-orm/pglite';
import { migrate } from 'drizzle-orm/pglite/migrator';
import { fileURLToPath } from 'node:url';
import { of } from 'rxjs';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { ANALYTICS_SERVICE, AppModule } from '../src/app.module.js';
import * as schema from '../src/database/schema.js';
import { MailerService } from '../src/notifications/mailer.service.js';
import { OrdersService } from '../src/orders/orders.service.js';

describe('OrdersService (outbox)', () => {
  const client = new PGlite(); // PostgreSQL, in-process
  const db = drizzle(client, { schema });
  const mailer = { sendOrderConfirmation: vi.fn() };
  const analytics = { emit: vi.fn(() => of(undefined)) };
  let moduleRef: TestingModule;
  let orders: OrdersService;
  let relay: OutboxRelay;

  beforeAll(async () => {
    // Your migrations: the order tables. The outbox's store creates its schema in init().
    await migrate(db, { migrationsFolder: fileURLToPath(new URL('../drizzle', import.meta.url)) });
    process.env.OUTBOX_RELAY = 'off'; // no poll loop: the test drives the relay
    moduleRef = await Test.createTestingModule({ imports: [AppModule] })
      .overrideProvider(getDrizzleToken())
      .useValue(db) // the outbox's store runs on it too
      .overrideProvider(MailerService)
      .useValue(mailer)
      .overrideProvider(ANALYTICS_SERVICE)
      .useValue(analytics)
      .compile();
    await moduleRef.init();
    orders = moduleRef.get(OrdersService);
    relay = moduleRef.get(OutboxRelay);
  });

  afterAll(async () => {
    await moduleRef.close();
    await client.close();
  });

  it('publishes order events only after the order commits', async () => {
    const order = await orders.placeOrder({ userId: 'user-42', items: [{ productId: 'salmon-kibble-2kg', quantity: 1 }] });
    expect(mailer.sendOrderConfirmation).not.toHaveBeenCalled();

    await relay.runOnce(); // claim and publish one batch

    expect(mailer.sendOrderConfirmation).toHaveBeenCalledWith(order);
    expect(analytics.emit).toHaveBeenCalledWith(
      'analytics.order.placed',
      expect.objectContaining({ key: order.id, payload: order }),
    );
    expect(await relay.stats()).toMatchObject({ pending: 0, deadLetters: 0 });
  });

  it('publishes nothing when the transaction rolls back', async () => {
    const outbox = moduleRef.get(Outbox);
    const add = outbox.add.bind(outbox);
    vi.spyOn(outbox, 'add').mockImplementationOnce(async (tx, messages) => {
      await add(tx, messages); // the messages are written...
      throw new Error('Connection terminated unexpectedly'); // ...then the transaction fails
    });

    await expect(
      orders.placeOrder({ userId: 'user-42', items: [{ productId: 'salmon-kibble-2kg', quantity: 1 }] }),
    ).rejects.toThrow('Connection terminated unexpectedly');

    expect(await relay.stats()).toMatchObject({ pending: 0 });
    expect(await relay.runOnce()).toMatchObject({ claimed: 0 });
  });
});
  • The outbox, its store, the migrations and the in-process dispatch are real. Only the mailer and the analytics client are stubbed.
  • Override the database DrizzleModule registers, whose token getDrizzleToken() returns, with the PGlite one. The store's factory injects the same token, so the orders and the outbox share the database, as they share the pool in production, and the store creates its schema there when the module initializes. The module still opens its pg pool, which never connects.
  • The rollback test lets outbox.add() write the messages, then fails the transaction. The order and its messages disappear together, and runOnce() finds nothing to claim.
  • For behavior that depends on time, such as backoff and lease expiry, move Date.now() forward with vi.spyOn(Date, 'now'). The relay takes the time from it, for itself and for the store, and real timers keep running for sockets and HTTP.
  • PGlite has a single connection, so it runs transactions one at a time. Test races between instances against a PostgreSQL server, the same major version as production, as the example does for several instances.
  • To keep the outbox out of a test, replace the store: overrideProvider(PostgresOutboxStore).useValue(new InMemoryOutboxStore()). A plain instance doesn't register itself, so the outbox falls back to its in-memory store, and the store's schema isn't created. It can't join your Drizzle transactions, though: a rolled-back message is still published, and the store logs a warning the first time it gets your transaction.

Delivery guarantees#

The overall guarantee is at least once, with consumer inboxes absorbing the duplicates.

SituationOutcome
The order transaction rolls backThe message never existed and is never published
The order transaction commitsPublished eventually, or dead-lettered with its error history
A relay dies after claimingPublished by another instance once its lease expires
A relay dies after publishing, before recording itPublished twice. Inboxes drop the duplicate
A publish or handler failsRetried with backoff, then dead-lettered (exhausted)
A publish or handler outlives publishTimeoutCounted as a failed attempt, and ctx.signal aborts. A handler that ignores it keeps running; a retry in the same process waits for it
NonRetryableMessageError, or retryIf returns falseDead-lettered at once (rejected)
Messages share a keyPublished in the order they were added. A retrying message holds back the rest of its key
A message is dead-letteredIts key is unblocked, so later messages with that key overtake it
TCP, Redis or core NATS transport"Published" means the write left the process. A consumer crash after that loses the message

Order within a key is the order in which the store numbered the messages, not their ids: an id comes from its instance's clock, and clocks differ. PostgreSQL runs transactions concurrently, so the store makes the transactions that add messages with the same key take turns, and the numbering follows commit order (see Keep messages in your database).

Production checklist#

  • Register a store on your application's database, on a database server: PostgresOutboxStore, MySqlOutboxStore, or one of your own (The store contract). Never keep the outbox on the instance itself, in memory or in a file database such as SQLite on its disk: a container's filesystem goes away when the task is replaced, and every message that hadn't been published goes with it. The production guard refuses to start without a store; set allowInMemoryStorage only for a process whose messages you can afford to lose.
  • Apply the store's migrations before a new version starts, with npx nest-outbox migrate or your migration tool, and check them in CI with npx nest-outbox status. With NODE_ENV=production, the store doesn't migrate at startup: a schema that's behind fails it instead.
  • On PostgreSQL, keep the database's default isolation level at READ COMMITTED, the default: the store refuses a stricter one at startup.
  • Alert on lagMs and deadLetters from relay.stats(), and on dead-lettered events, from OutboxEvents or the nestjs:outbox:dead-lettered diagnostics channel.
  • Size retry to how long your downstreams can be down. The package default is 20 attempts, about 30 to 60 minutes in total. This tutorial's 10 attempts capped at 1 minute give up after 2 to 4 minutes, and then a key's later messages overtake the dead-lettered one.
  • Keep consumer names stable. Inbox entries are keyed by them, so a renamed consumer sees every past message as new.
  • Prune inboxes. Nothing calls OutboxInbox.prune() for you. Run it from a cron job, for example inbox.prune('30d'), with a window longer than any redelivery, requeues included.
  • Keep handlers shorter than publishTimeout, and pass ctx.signal to the calls they make. A slower handler counts as a failed attempt; leases aren't extended.
  • Call app.enableShutdownHooks(), so deploys drain the relay instead of leaving messages leased. DrizzleModule ends the pool after that, in onApplicationShutdown().
  • Protect the dead-letter routes. They expose full payloads.
  • Version payloads. A message added by the previous release can still be delivered after a deploy.
  • Give each ordered stream its own key, and don't share a key between messages for different transports.
  • Keep transactions that call outbox.add() short. The per-key lock makes transactions adding messages with the same key take turns. Prisma's interactive transactions time out after 5 seconds by default (transactionOptions on the client, or per call).
  • On MySQL, run a transaction that calls outbox.add() again when it fails with a deadlock (error 1213): MySQL rolled all of it back.
  • Size the connection pool for both your requests and the relay: each claim holds a connection for one short transaction.
  • Run the contract suites with concurrent: true in CI against a store of your own, on a pool, against the database engine and major version you run in production.

The store contract#

PostgresOutboxStore and MySqlOutboxStore implement the package's two storage interfaces: OutboxStore for the messages and dead letters, and OutboxInboxStore for the consumers' inboxes. For another database, implement them in a provider of your own. The JSDoc of each method on those interfaces says what it must do, and which race each rule prevents.

Registration. The store is an ordinary singleton provider that calls registerSource() on the injectable OutboxStorage in its constructor, for the contracts it implements, by name: messages and inbox. A service that only consumes may register an inbox store alone. The registry checks the shape at once (every method is a function), refuses a second registration unless it passes replace: true, and locks when OutboxModule initializes, logging the store in use. A request-scoped provider, a provider in a lazy-loaded module, or a lifecycle hook registers too late, and throws.

With nothing registered, the outbox runs on its in-memory store, and says so at startup:


[Nest] 19039  - 09/23/2026, 11:28:45 AM     LOG [OutboxModule] OutboxStorage: InMemoryOutboxStore (the default: state is lost on restart and not shared between instances)

That is more than a lost-on-restart shortcut. An in-memory store can't join your transaction, so it applies add() at once, and warns the first time it receives a transaction handle. In this run, the order's transaction added its message and then failed, and the confirmation email went out anyway:


[Nest] 19039  - 09/23/2026, 11:28:45 AM    WARN [OutboxModule] InMemoryOutboxStore.add() received your transaction handle, and can't join it: the write applies at once, so a rolled-back transaction won't undo it. Register a store for your database with OutboxStorage.registerSource(). (Logged once.)
[Nest] 19039  - 09/23/2026, 11:28:46 AM     LOG [MailerService] Order confirmation for 7435d418-5d9a-48a0-b186-6cfd2e34b9f4 sent to user-7

With NODE_ENV=production, the same application doesn't start:


Error: OutboxStorage: no store is registered for `messages` (OutboxStore) and `inbox` (OutboxInboxStore), and NODE_ENV is "production": in memory, messages and inbox records would be lost on restart and not shared between instances. Register a store on your database: PostgresOutboxStore (@nestjs/outbox/postgres), MySqlOutboxStore (@nestjs/outbox/mysql), or your own OutboxStore and OutboxInboxStore in a provider that injects OutboxStorage and calls `storage.registerSource({ messages: this, inbox: this })` in its constructor. Or set `allowInMemoryStorage: true` in the OutboxModule options to run in memory anyway.

allowInMemoryStorage: true in the module's options is the deliberate way out, for a single instance that may lose its state.

Atomicity. Producers, relays and deliveries race each other through the store. add() must number a key's messages in the order their transactions commit, claim() must never lease a message to two relays or run ahead of an older message of its key, a relay's later writes must apply only while its lease holds, and two deliveries of one message must meet at recordInbox(). So every method that changes state is one conditional statement, or one transaction with the right lock, never a read followed by a write. add() and recordInbox() receive your transaction object untouched: they must write through it, and refuse anything that isn't one, such as the database itself, with OutboxTransactionRequiredError.

Test it with the suites from @nestjs/outbox/testing. outboxStoreContract() and outboxInboxStoreContract() return the contracts as test cases for any test runner: each case is a name and a run() function that throws on failure. The tutorial's tests run them against PostgresOutboxStore through Drizzle, as you would against yours, with a function that returns the store on emptied tables, a way to open one of your transactions, and notATransaction, a handle the store must refuse:

test/drizzle-outbox.store.e2e-spec.ts
JS TS

/** A store on emptied tables, built the way Nest builds it: with the database and a registry. */
async function freshStore(db: Database): Promise<OutboxStoreHarness<Transaction> & { store: PostgresOutboxStore }> {
  await db.execute(sql`TRUNCATE nest_outbox.messages, nest_outbox.dead_letters, nest_outbox.inbox RESTART IDENTITY`);
  return {
    store: new PostgresOutboxStore({ executor: fromDrizzle(db) }, new OutboxStorage()),
    transaction: (work) => db.transaction(work),
    notATransaction: db,
  };
}

describe('PostgresOutboxStore through fromDrizzle on PGlite: the store contract', () => {
  // ...
  // The concurrency cases run too; with one connection, PGlite runs them one transaction at a time.
  for (const c of outboxStoreContract(() => freshStore(db), { concurrent: true })) {
    it(c.name, c.run);
  }
  describe('the inbox contract', () => {
    for (const c of outboxInboxStoreContract(() => freshStore(db), { concurrent: true })) {
      it(c.name, c.run);
    }
  });
});

concurrent: true adds the races a naive store fails: producers of a key whose transactions overlap, relays claiming side by side, producers and relays racing, a stale relay's writes racing a takeover, and two deliveries of one message. On PGlite they pass one transaction at a time, so run them against a server with a pool too, where the transactions really overlap, on the database engine and major version you run in production. The tutorial runs both suites through each of its clients: Drizzle on PGlite and on PostgreSQL, TypeORM and Prisma on PostgreSQL, and Drizzle on MySQL, against MySqlOutboxStore. The package's own tests run them against stores written by hand with Drizzle, TypeORM and Prisma too, as the proof that a store written against the interfaces passes them.

Other packages' stores can live elsewhere: the idempotency store fits Redis. The outbox's store can't, because its messages must commit with your rows, in your database.

Using BullMQ on Postgres instead#

BullMQ 6 has a PostgreSQL backend, so it's fair to ask whether its job table could be the outbox. The package's research into BullMQ 6.3.x (from reading its published code, not a live run) says: not today.

  • Queue.add() always runs on BullMQ's own connection pool, in autocommit mode. The backend interface deliberately exposes no connection or transaction type, so an add() can't join the transaction that inserts your order.
  • The enqueue itself is one SQL function, bullmq.add_job(...). Calling it on your own transaction would be atomic, but the function is internal, its signature changes with BullMQ's schema migrations, and every transaction that calls it would take Postgres's global NOTIFY lock at commit.
  • @nestjs/bullmq 12.0.0 can't select the Postgres backend for a single queue. The only way is BullMQ's process-wide setDefaultBackendFactory().

What's needed is an upstream option to run add() on a caller-supplied client (so the job commits with the order), plus a backend option in @nestjs/bullmq. With both, the job table would be the outbox, and BullMQ's workers would provide the claims, retries and failed set with no relay in between. Until then, use this outbox and publish to BullMQ from an OutboxTransport that calls queue.add(topic, payload) with the outbox message id as the jobId, so a republished message is ignored while the job still exists.

Example#

A working example is available in the 37-outbox sample: the order API on PostgreSQL with Drizzle, its in-process handlers, the analytics microservice with a database of its own, the dead-letter admin API, and the tests.

Reference#

Module options#

OutboxModule.forRoot() takes these options. forRootAsync() takes transports classes, imports and isGlobal at its top level, and the rest from useFactory, useClass or useExisting (a class implementing OutboxOptionsFactory), as in Register the outbox. The options token is OUTBOX_MODULE_OPTIONS. The store isn't an option: register it as a provider, as in Keep messages in your database. Durations are milliseconds, or strings such as '30s' and '1m'.

OptionDefaultMeaning
transportsNoneNamed OutboxTransport classes or instances. local, the @OnOutboxMessage() handlers, is built in and reserved
importsNoneModules whose exports the transport classes inject
routeThe only destination(message) => name: the one transport a message goes to. Required with handlers and transports, or several transports
relay.enabledtruefalse in instances that only produce or only consume
relay.pollInterval'1s'The wait between polls when idle
relay.batchSize100Messages per claim
relay.lease'30s'How long a claim is exclusive
relay.concurrency10Key groups published in parallel within a batch
relay.publishTimeoutA third of leaseA slower publish counts as a failed attempt, and its signal aborts
retry.attempts20Total attempts, including the first, before the message is dead-lettered. retry: 5 means 5 attempts; retry: false, a single attempt
retry.backoff'1s' doubling up to '5m', equal jitterAn object with delay, factor, maxDelay and jitter ('equal', 'full' or 'none'), or a function (attempt, error, message) => duration
retry.retryIfEvery error(error, attempt, message) => boolean: false dead-letters the message at once
allowInMemoryStoragefalseWith NODE_ENV=production and no registered store, startup fails; true runs on the in-memory store anyway
isGlobaltrueRegisters the module globally
Stores#

new PostgresOutboxStore(options, outboxStorage), from @nestjs/outbox/postgres, and new MySqlOutboxStore(options, outboxStorage), from @nestjs/outbox/mysql, take these options. Without outboxStorage, the store registers nowhere, which suits a script or a test.

OptionDefaultMeaning
executorRequiredHow the store reaches the database: one of the executors below, from the same entry point as the store. The other dialect's doesn't compile
schema'nest_outbox'On PostgreSQL, the schema of its tables, which its first migration creates: letters, digits and underscores, not starting with a digit, at most 63 characters. On MySQL, the prefix of its tables in the connection's database: lowercase letters, digits and underscores, not starting with a digit, at most 40 characters
migratetrue, except with NODE_ENV=productionApply the pending migrations when the application starts. With false, startup fails with an OutboxSchemaError while the schema is behind

Each executor takes your database client, and its own kind of transaction object, which outbox.add() and processInTransaction() then take:

ExecutorTakesTransaction object
fromDrizzle(db)A Drizzle database: on PostgreSQL, whichever its driver; on MySQL, mysql2The tx that db.transaction() hands its callback
fromTypeOrm(dataSource)A DataSource of type postgres or mysqlThe EntityManager that dataSource.transaction() hands its callback, or a QueryRunner after startTransaction()
fromPrisma(prisma, options)A Prisma client, through a driver adapter such as @prisma/adapter-pg, or @prisma/adapter-mariadb for MySQL. maxWait (default '10s') and timeout (default '1m') limit the store's own transactionsThe transaction client that prisma.$transaction() hands its callback
fromKysely(db)A Kysely instance with a PostgreSQL or MySQL dialectThe transaction that db.transaction().execute() hands its callback
fromPg(pool)PostgreSQL: a node-postgres Pool, or a connected ClientA client, such as one from pool.connect(), after BEGIN
fromMysql2(pool)MySQL: a mysql2/promise pool, or a connectionA connection, such as one from pool.getConnection(), after beginTransaction()

The store's migrate() method applies the pending migrations at once, whatever the migrate option says, and resolves with the versions it applied. The static migrationSql() returns their SQL, with the bookkeeping, and MySqlOutboxStore.migrationStatements() returns the same statements, one per string. They take the schema, the versions to go from (default 0, a new database) and to (default: the one the package needs), and migrationSql() takes statementBreakpoints too. From version 0 on PostgreSQL, the SQL starts with CREATE SCHEMA IF NOT EXISTS, which needs the CREATE privilege on the database: leave it out if someone created the schema for you. The static schemaVersion is the version the package needs.

The command line, npx nest-outbox, takes --url (default DATABASE_URL) and --schema (default nest_outbox). A mysql:// URL picks MySqlOutboxStore's migrations, and needs mysql2 installed:

CommandWhat it does
migrateApplies the pending migrations, as migrate: true does at startup
statusPrints the schema's version and the one the package needs, and exits with 1 while the schema is behind
sqlPrints the SQL of migrationSql(), from --from to --to, without a database: PostgreSQL's, or MySQL's with --dialect mysql. --statement-breakpoints separates the statements for a drizzle-kit custom migration

On MySQL, the store needs MySQL 8.4 LTS or 9.x with a strict sql_mode, MySQL's default: a lax sql_mode, or MariaDB, fails the startup.

Messages and handlers#

A message passed to outbox.add(tx, message) (Save the order and its messages in one transaction) has these fields:

FieldRequiredMeaning
topicYesWhat route and the handlers match on
payloadYesSerialized to JSON when add() runs
keyNoThe ordering key: messages that share it are published one at a time, in commit order
headersNoString headers, passed to transports
idNoYour own unique id. The default is a UUIDv7; consumers deduplicate by it
delay or availableAtNoPublish no earlier than this long from now, or than this point in time. Not both
  • @OnOutboxMessage(topic, options) takes consumer (required: the name of the handler's inbox) and inbox (default true; false runs every delivery). Handle order.placed in-process
  • A handler's context has message, consumer, attempt, signal and processInTransaction(tx, work).
  • ClientProxyTransport(token, options) emits the topic with an envelope of id, topic, key, headers, createdAt and payload (OutboxEnvelope). Its toPacket(message, envelope) option returns your own pattern and data. Publish to the analytics microservice
Services#
ServiceMethods
Outboxadd(tx, messages), notify()
OutboxRelaystats(): pending, ready, leased, deadLetters, oldestDueAt, lagMs, inFlight. runOnce(): claimed, published, retried, deadLettered, released, leaseLost. Also notify(), start() and stop()
OutboxDeadLetterslist(query) (by topic, key, limit, offset; newest first), get(id), requeue(target), purge(target). A target is an id, an array of ids, or a filter by ids, topic, key, failedBefore, or all: true. Retries and the dead-letter queue
OutboxInboxprocess(consumer, id, work), processInTransaction(tx, consumer, id, work), prune(olderThan). Both process methods resolve duplicate, and result when work ran
Events#

OutboxEvents.events$ emits each event, and publishes it on its node:diagnostics_channel channel. Every payload has type and the message. Run several instances counts them.

EventChannelOther fieldsWhen
publishednestjs:outbox:publishedtransport, durationMsA transport accepted the message
retry-schedulednestjs:outbox:retry-scheduledtransport, error, attempt, delayMsA publish failed and will be retried
dead-letterednestjs:outbox:dead-letteredtransport, error, attempt, reasonThe message moved to the dead letters. Alert on it
lease-lostnestjs:outbox:lease-lostNoneAnother relay took the message over mid-publish, so it may be published twice

A dead letter's reason is exhausted (the retries ran out) or rejected (a NonRetryableMessageError, or retryIf returned false).

Errors#

None of them carries an HTTP status. OutboxError is the base class of the four that the package throws.

ErrorRaised when
OutboxTransactionRequiredErroradd() or processInTransaction() got no transaction handle, or a store refused a handle that isn't a transaction
OutboxPublishTimeoutErrorA publish outlived relay.publishTimeout (timeoutMs). Counted as a failed attempt
OutboxNoHandlerErrorNo @OnOutboxMessage() handler for the topic in this process (topic). Retried
OutboxSchemaErrorThe store's schema is behind the package's migrations while migrate is off, or applying them failed (cause): startup and every call fail until it's fixed. With the schema, its version and the requiredVersion. From @nestjs/outbox/postgres and @nestjs/outbox/mysql
NonRetryableMessageErrorYou throw it from a handler or transport to dead-letter the message without retrying
Edit on GitHub

Support us

Nest is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you'd like to join them, please read more here.

Principal Sponsors

SerpApi LogoTrilon LogoMojam Logo

Sponsors / Partners

Become a sponsor