All posts
20 Aug 2026

Reliable Event Publishing in Node.js: Implementing the Transactional Outbox Pattern

A code-heavy, practical guide to solving the dual-write problem in Node.js microservices by pairing database transactions with BullMQ and Redis using the Transactional Outbox Pattern.

Reliable Event Publishing in Node.js: Implementing the Transactional Outbox Pattern

In a modern microservices architecture, services communicate asynchronously via events. When a state change occurs in a primary service (e.g., a user updates their profile or an order is placed), that service must persist the change to its local database and publish an event to a message broker like Redis, RabbitMQ, or Kafka.

This seemingly simple requirement introduces a notorious distributed systems challenge: The Dual-Write Problem.

The Dual-Write Problem

Imagine an Order Service handling a checkout operation. The business logic requires two distinct actions:

  1. Write the order record to the PostgreSQL database.
  2. Publish an OrderCreated event to a message queue so downstream services (inventory, billing) can react.

Because databases and message brokers are fundamentally separate systems, you cannot wrap both operations in a single atomic ACID transaction. Consider what happens if you write to the database first, but the network drops before you can publish to the message broker:

code
[PostgreSQL: Order Written] ──X──> [Message Broker: Fails to Publish]

Your database has the order, but downstream services are completely unaware of it. Conversely, if you publish to the message broker first and your database transaction subsequently rolls back, you’ve broadcasted an event for an entity that doesn’t actually exist.

Why Application-Level Retries Fall Short

You might think: “I’ll just wrap the message publishing in a try/catch block with exponential backoff.”

While this helps with transient network blips, it fails catastrophically when your Node.js process crashes, runs out of memory, or loses power between the database commit and the event publication.

// ANTI-PATTERN: Do not do this in production
async function createOrder(orderData) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const order = await insertOrder(client, orderData);
    await client.query('COMMIT');

    // CRITICAL FAILURE POINT:
    // If the process crashes here, the event is lost forever.
    await messageQueue.add('OrderCreated', order);
    
    return order;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

To achieve absolute reliability, we need the Transactional Outbox Pattern.


The Transactional Outbox Pattern Explained

The Outbox Pattern solves the dual-write problem by turning the message publication into a database operation.

  1. The Outbox Table: You introduce an outbox table in the same database as your domain entities.
  2. The Atomic Write: Within a single database transaction, you write your domain data (e.g., orders) AND you write an outbox event record.
  3. The Relay Process: A separate background worker polls the outbox table, reads unpublished events, publishes them to the message broker (e.g., BullMQ/Redis), and marks them as processed.
┌────────────────────────────────────────────────────────┐
│                      PostgreSQL                        │
│                                                        │
│  ┌──────────────┐        ┌───────────────────────────┐ │
│  │    Orders    │        │      Outbox Table         │ │
│  │  (Domain)    │        │ (Pending Events to Publish│ │
│  └──────────────┘        └─────────────┬─────────────┘ │
└────────────────────────────────────────┼───────────────┘
                                         │
                                         ▼
                           ┌───────────────────────────┐
                           │ Outbox Relay Worker (Cron)│
                           └─────────────┬─────────────┘
                                         │
                                         ▼
                           ┌───────────────────────────┐
                           │    Redis / BullMQ Queue   │
                           └───────────────────────────┘

Let’s build a robust, production-ready implementation of this pattern in Node.js using PostgreSQL, BullMQ, and Redis.


Step 1: Database Schema Design

First, we need our database tables. We will create an orders table and an outbox_events table.

CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id UUID NOT NULL,
    total_amount NUMERIC(10, 2) NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(255) NOT NULL,
    aggregate_id UUID NOT NULL,
    event_type VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    processed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Index for fast polling of unprocessed events
CREATE INDEX idx_outbox_unprocessed ON outbox_events (created_at) WHERE processed = FALSE;

Step 2: Writing to the Outbox within a Transaction

Next, let’s implement our service layer. When an order is created, we use a single database transaction to insert both the order and the outbox event.

// orderService.ts
import { Pool, PoolClient } from 'pg';
import { v4 as uuidv4 } from 'uuid';

interface CreateOrderDTO {
  customerId: string;
  totalAmount: number;
}

export class OrderService {
  constructor(private dbPool: Pool) {}

  async createOrder(dto: CreateOrderDTO) {
    const client: PoolClient = await this.dbPool.connect();
    
    try {
      await client.query('BEGIN');

      // 1. Insert the domain entity
      const orderId = uuidv4();
      const orderQuery = `
        INSERT INTO orders (id, customer_id, total_amount, status)
        VALUES ($1, $2, $3, 'PENDING')
        RETURNING *
      `;
      const orderResult = await client.query(orderQuery, [
        orderId,
        dto.customerId,
        dto.totalAmount,
      ]);
      const order = orderResult.rows[0];

      // 2. Insert the outbox event atomically within the same transaction
      const outboxQuery = `
        INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
        VALUES ($1, $2, $3, $4)
      `;
      
      const eventPayload = {
        orderId: order.id,
        customerId: order.customer_id,
        totalAmount: order.total_amount,
        createdAt: order.created_at,
      };

      await client.query(outboxQuery, [
        'Order',
        order.id,
        'OrderCreated',
        JSON.stringify(eventPayload),
      ]);

      // 3. Commit the transaction. Both records are guaranteed to persist together.
      await client.query('COMMIT');

      return order;
    } catch (error) {
      await client.query('ROLLBACK');
      console.error('Failed to create order, transaction rolled back:', error);
      throw error;
    } finally {
      client.release();
    }
  }
}

At this stage, even if your Node.js application crashes immediately after COMMIT, your database securely holds both the order record and the publishing intent inside outbox_events.


Step 3: Implementing the Outbox Relay (Publisher Worker)

Now we need a background worker that regularly sweeps the outbox_events table, publishes the payloads to BullMQ/Redis, and marks them as processed.

We will use BullMQ for our message broker due to its reliability and Redis backing.

// outboxPublisher.ts
import { Pool } from 'pg';
import { Queue } from 'bullmq';

export class OutboxPublisher {
  private isRunning = false;
  private timer: NodeJS.Timeout | null = null;

  constructor(
    private dbPool: Pool,
    private eventQueue: Queue,
    private pollIntervalMs: number = 2000,
    private batchSize: number = 100
  ) {}

  public start() {
    if (this.isRunning) return;
    this.isRunning = true;

    console.log('Starting Outbox Publisher Worker...');
    this.timer = setInterval(() => this.processOutboxEvents(), this.pollIntervalMs);
  }

  public stop() {
    if (!this.isRunning) return;
    this.isRunning = false;
    if (this.timer) clearInterval(this.timer);
    console.log('Outbox Publisher Worker stopped.');
  }

  private async processOutboxEvents() {
    const client = await this.dbPool.connect();

    try {
      // Fetch a batch of unprocessed events, locking rows to prevent race conditions in multi-instance deployments
      await client.query('BEGIN');
      
      const selectQuery = `
        SELECT id, event_type, payload 
        FROM outbox_events 
        WHERE processed = FALSE 
        ORDER BY created_at ASC 
        LIMIT $1 
        FOR UPDATE SKIP LOCKED
      `;
      
      const result = await client.query(selectQuery, [this.batchSize]);
      const events = result.rows;

      if (events.length === 0) {
        await client.query('COMMIT');
        return;
      }

      for (const event of events) {
        try {
          // Publish to BullMQ
          await this.eventQueue.add(event.event_type, event.payload, {
            // Use event ID as job ID for idempotency/deduplication if needed
            jobId: event.id,
            attempts: 3,
            backoff: {
              type: 'exponential',
              delay: 1000,
            },
          });

          // Mark event as processed
          await client.query(
            'UPDATE outbox_events SET processed = TRUE WHERE id = $1',
            [event.id]
          );
        } catch (pubError) {
          console.error(`Failed to publish outbox event ${event.id}:`, pubError);
          // Break the loop so we don't mark subsequent items or commit partially if required,
          // or let individual retries happen. Here we abort the batch transaction.
          throw pubError;
        }
      }

      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      console.error('Error processing outbox batch:', error);
    } finally {
      client.release();
    }
  }
}

Key Architectural Features of the Worker:

  • FOR UPDATE SKIP LOCKED: This is a powerful PostgreSQL clause. If you run multiple instances of your Node.js microservice for high availability, SKIP LOCKED ensures that Worker Instance A and Worker Instance B never grab and publish the same outbox event concurrently.
  • Batching: Processing events in batches (LIMIT 100) optimizes network and database round-trips under high load.
  • Atomicity per Batch: Marking events as processed inside the same transaction loop ensures that if publishing to Redis throws an unhandled error, the transaction rolls back, and the events remain processed = FALSE for the next polling cycle.

Step 4: Wiring It All Together

Let’s initialize our application infrastructure by tying the PostgreSQL connection pool, BullMQ queue, and Outbox publisher together.

// index.ts
import { Pool } from 'pg';
import { Queue } from 'bullmq';
import { OrderService } from './orderService';
import { OutboxPublisher } from './outboxPublisher';

async function bootstrap() {
  // 1. Initialize PostgreSQL Pool
  const dbPool = new Pool({
    connectionString: process.env.DATABASE_URL || 'postgres://user:pass@localhost:5432/orders_db',
  });

  // 2. Initialize BullMQ Queue
  const eventQueue = new Queue('domain-events', {
    connection: {
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379'),
    },
  });

  // 3. Initialize Services
  const orderService = new OrderService(dbPool);
  const outboxPublisher = new OutboxPublisher(dbPool, eventQueue);

  // 4. Start the background relay
  outboxPublisher.start();

  // Example Usage: Simulate creating an order
  try {
    const newOrder = await orderService.createOrder({
      customerId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
      totalAmount: 149.99,
    });
    console.log('Order successfully created via transaction:', newOrder.id);
  } catch (err) {
    console.error('Order creation failed');
  }

  // Graceful shutdown handling
  process.SIGINTHandler = async () => {
    console.log('Shutting down gracefully...');
    outboxPublisher.stop();
    await eventQueue.close();
    await dbPool.end();
    process.exit(0);
  };
}

bootstrap().catch(console.error);

Handling Edge Cases and Production Hardening

While the implementation above is robust, running the Outbox Pattern at scale in production requires addressing a few operational realities:

1. At-Least-Once Delivery & Idempotency

Because network partitions and crashes can happen right after publishing to BullMQ but before updating outbox_events.processed = TRUE, outbox relays guarantee at-least-once delivery. This means downstream microservices must be idempotent. Downstream consumers should store processed event IDs (e.g., in a deduplication table) to safely drop duplicate events.

2. Table Bloat and Garbage Collection

Your outbox_events table will grow rapidly under heavy traffic. You must implement a retention policy to purge processed events.

-- Run via a daily cron job or scheduled worker
DELETE FROM outbox_events 
WHERE processed = TRUE 
  AND created_at < NOW() - INTERVAL '7 days';

3. Monitoring Lag

Monitor the size of your unprocessed outbox backlog using a simple Prometheus metric query:

SELECT COUNT(*) FROM outbox_events WHERE processed = FALSE;

If this count trends upward, it indicates your Redis instance, network, or worker pool is bottlenecked.


Conclusion

Distributed systems require defensive software engineering. Relying on application-level try/catch blocks for message publishing is an invitation for silent data corruption and lost events in microservices.

By implementing the Transactional Outbox Pattern with PostgreSQL and BullMQ in Node.js:

  • You eliminate the dual-write problem.
  • You guarantee that every state change results in a published event, even during abrupt application crashes.
  • You maintain horizontal scalability safely using PostgreSQL’s FOR UPDATE SKIP LOCKED mechanism.

Adopt this pattern early in your distributed architecture to build resilient, fault-tolerant backend services.

More posts