All posts
30 Aug 2026

Implementing Distributed Transactions: Saga Pattern vs. 2PC in Node.js Microservices

A practical, code-heavy architectural guide comparing choreography-based and orchestration-based Sagas against Two-Phase Commit (2PC) in Node.js microservices.

Implementing Distributed Transactions: Saga Pattern vs. 2PC in Node.js Microservices

Transitioning from a monolithic architecture to microservices introduces a fundamental challenge: data decentralization. In a monolith, a single ACID transaction ensures that if an operation fails, everything rolls back seamlessly. In a microservices architecture, data is split across multiple databases owned by independent services.

How do you execute a business process—such as placing an e-commerce order—that spans the Order Service, Payment Service, and Inventory Service without leaving your system in an inconsistent state?

In this deep dive, we will explore and implement the two primary solutions for distributed transactions in a Node.js ecosystem: Two-Phase Commit (2PC) and the Saga Pattern (covering both choreography and orchestration styles with concrete failure-handling and compensation logic).

—.png

The Distributed Transaction Dilemma

Before diving into code, let’s understand the landscape. Suppose a user places an order. The following steps must occur:

  1. Order Service: Create an order with status PENDING.
  2. Inventory Service: Reserve the ordered items.
  3. Payment Service: Process the customer’s credit card.
  4. Order Service: Update the order status to CONFIRMED.

If the Payment Service fails due to insufficient funds, steps 1 and 2 must be undone. How do we achieve this guarantee in Node.js?

—.png

Approach 1: Two-Phase Commit (2PC)

Two-Phase Commit is an atomic commitment protocol used to ensure that all participating databases commit or abort a transaction together. It relies on a coordinator.

How 2PC Works

  1. Phase 1 (Prepare): The coordinator asks all participant databases if they are ready to commit. Each database locks its resources and replies with VOTE_COMMIT or VOTE_ABORT.
  2. Phase 2 (Commit/Abort): If all participants vote yes, the coordinator issues a COMMIT command. If any participant votes no, the coordinator issues a ROLLBACK command.

Why 2PC Fails in Modern Microservices

While 2PC provides strong consistency, it is a blocking protocol. While waiting for votes and acknowledgments, database locks are held open. In distributed Node.js microservices communicating over HTTP or gRPC, network latency or service crashes can lock up resources indefinitely, severely degrading throughput and availability. Furthermore, many modern NoSQL databases (like MongoDB or DynamoDB) do not support XA/2PC transactions across nodes.

—.png

Approach 2: The Saga Pattern

Coined by Hector Garcia-Molina and Kenneth Salem, the Saga Pattern manages distributed transactions through a sequence of local transactions. Each service updates its local database and publishes an event or message. If a local transaction fails, the saga executes a series of compensating transactions that undo the changes made by preceding steps.

Sagas sacrifice Atomicity (ACID) for Eventual Consistency (BASE: Basically Available, Soft state, Eventual consistency), making them ideal for high-throughput Node.js microservices.

There are two main ways to implement Sagas:

  1. Choreography-based Saga: Decentralized. Services listen to events and decide what to do next.
  2. Orchestration-based Saga: Centralized. A dedicated orchestrator service tells each participant what operation to perform next.

—.png

Implementing a Choreography-Based Saga in Node.js

In a choreographed saga, there is no central coordinator. Services communicate via an event broker (e.g., RabbitMQ, Apache Kafka, or Redis Streams). Let’s implement an order placement saga using Node.js and Redis Streams.

1. Order Service (Initiator)

javascript
// order-service.js
const Redis = require('ioredis');
const redis = new Redis();

async function createOrder(orderData) {
  const orderId = generateId();
  // 1. Save local state as PENDING
  await db.orders.insert({ id: orderId, status: 'PENDING', ...orderData });
  console.log(`[OrderService] Order ${orderId} created as PENDING.`);

  // 2. Publish event to trigger Inventory Service
  await redis.xadd('order-events', '*', 
    'event', 'OrderCreated',
    'orderId', orderId,
    'items', JSON.stringify(orderData.items),
    'amount', orderData.amount
  );
}

2. Inventory Service (Listener & Compensator)

// inventory-service.js
const Redis = require('ioredis');
const redis = new Redis();

async function handleEvents() {
  let lastId = '0';
  while (true) {
    const streams = await redis.xread('STREAMS', 'order-events', lastId);
    if (streams) {
      for (const [stream, messages] of streams) {
        for (const [id, fields] of messages) {
          lastId = id;
          const eventData = parseFields(fields);

          if (eventData.event === 'OrderCreated') {
            try {
              // Attempt inventory reservation
              await reserveInventory(JSON.parse(eventData.items));
              console.log(`[InventoryService] Inventory reserved for ${eventData.orderId}`);
              
              await redis.xadd('order-events', '*', 'event', 'InventoryReserved', 'orderId', eventData.orderId, 'amount', eventData.amount);
            } catch (err) {
              console.error(`[InventoryService] Failed to reserve inventory. Compensating...`);
              await redis.xadd('order-events', '*', 'event', 'InventoryFailed', 'orderId', eventData.orderId);
            }
          }
        }
      }
    }
  }
}

async function reserveInventory(items) {
  // Simulate DB operation that might throw error
  if (items.some(i => i.outOfStock)) throw new Error('Out of stock');
}

Pros and Cons of Choreography

  • Pros: Loose coupling, no single point of failure orchestrator.
  • Cons: Harder to track the workflow as it scales; risk of cyclic dependencies and messy event spaghetti.

—.png

Implementing an Orchestration-Based Saga in Node.js

For complex business workflows, orchestration is cleaner. A dedicated Saga Orchestrator manages the state machine, invoking services sequentially and triggering rollbacks if an error occurs.

Let’s build a robust, state-machine-driven orchestrator in Node.js using async/await and robust error handling.

Order Saga Orchestrator

// order-saga-orchestrator.js
const axios = require('axios');

class OrderSagaOrchestrator {
  constructor(orderPayload) {
    this.payload = orderPayload;
    this.orderId = null;
    this.state = 'STARTED';
  }

  async execute() {
    try {
      // Step 1: Create Order
      this.orderId = await this.executeOrderStep();
      
      // Step 2: Reserve Inventory
      await this.executeInventoryStep();

      // Step 3: Process Payment
      await this.executePaymentStep();

      // Step 4: Complete Order
      await this.completeOrderStep();

      console.log(`[Saga] Order ${this.orderId} completed successfully.`);
    } catch (error) {
      console.error(`[Saga] Failure detected: ${error.message}. Initiating rollback...`);
      await this.rollback();
    }
  }

  async executeOrderStep() {
    const res = await axios.post('http://localhost:4001/orders', this.payload);
    this.state = 'ORDER_CREATED';
    return res.data.orderId;
  }

  async executeInventoryStep() {
    try {
      await axios.post('http://localhost:4002/inventory/reserve', {
        orderId: this.orderId,
        items: this.payload.items
      });
      this.state = 'INVENTORY_RESERVED';
    } catch (err) {
      throw new Error('Inventory reservation failed');
    }
  }

  async executePaymentStep() {
    try {
      await axios.post('http://localhost:4003/payments', {
        orderId: this.orderId,
        amount: this.payload.amount
      });
      this.state = 'PAYMENT_PROCESSED';
    } catch (err) {
      throw new Error('Payment processing failed');
    }
  }

  async completeOrderStep() {
    await axios.post(`http://localhost:4001/orders/${this.orderId}/complete`);
    this.state = 'COMPLETED';
  }

  async rollback() {
    console.log(`[Saga Rollback] Current state before rollback: ${this.state}`);

    if (this.state === 'PAYMENT_PROCESSED' || this.state === 'INVENTORY_RESERVED') {
      try {
        await axios.post(`http://localhost:4002/inventory/release`, { orderId: this.orderId });
        console.log(`[Saga Rollback] Inventory released for order ${this.orderId}`);
      } catch (e) {
        console.error(`[CRITICAL] Failed to release inventory for ${this.orderId}: ${e.message}`);
        // In production: send to Dead Letter Queue (DLQ) for manual intervention
      }
    }

    if (this.state !== 'STARTED' && this.orderId) {
      try {
        await axios.post(`http://localhost:4001/orders/${this.orderId}/cancel`);
        console.log(`[Saga Rollback] Order ${this.orderId} marked as CANCELLED.`);
      } catch (e) {
        console.error(`[CRITICAL] Failed to cancel order ${this.orderId}: ${e.message}`);
      }
    }
  }
}

module.exports = OrderSagaOrchestrator;

Invoking the Orchestrator

// app.js
const OrderSagaOrchestrator = require('./order-saga-orchestrator');

async function handleCheckout(req, res) {
  const saga = new OrderSagaOrchestrator(req.body);
  // Execute asynchronously or await depending on UX requirements
  saga.execute(); 
  res.status(202).json({ message: 'Order processing started' });
}

—.png

Designing Reliable Compensating Transactions

Writing compensation logic is not always as simple as running an inverse SQL query. Consider these rules when implementing compensations in Node.js:

  1. Idempotency: Compensating actions must be idempotent. If a network timeout occurs and the orchestrator retries the rollback command, running it twice should yield the same result as running it once.
  2. Commutativity: Sometimes events arrive out of order. Design your database updates to handle out-of-order state transitions gracefully.
  3. Never Fail Compensations: A compensating transaction must not fail due to business logic. If a compensation fails due to a transient network error, your Node.js application must implement exponential backoff retries and fallback mechanisms (such as alerting an operations team or routing to a Dead Letter Queue).

—.png

Summary: Choosing the Right Strategy

Criteria Two-Phase Commit (2PC) Choreographed Saga Orchestrated Saga
Consistency Strong (ACID) Eventual (BASE) Eventual (BASE)
Availability Low (Blocking locks) High High
Complexity Low (Handled by DB/XAResource) Medium-High (Event spaghetti) Medium (Central state machine)
Best Used For Financial ledgers within a monolith/tight network Simple workflows, domain events Complex business workflows, explicit error handling

For 95% of modern microservice architectures built on Node.js, Two-Phase Commit is an anti-pattern. It introduces tight coupling and bottleneck latency.

Instead, embrace Eventual Consistency using the Saga Pattern. Choose Choreography if your microservices are lightweight and completely event-driven, or Orchestration when you need explicit control, audit trails, and robust failure compensation logic in your Node.js backend.

More posts