Arquitetura de Sistemas Distribuídos e Resiliência

Resilience Under Load Stress: A Comparative Analysis of Circuit Breaker, Retry, and Bulkhead in Node.js Microservices

This is a resilience case study: the payment-service simulates a real memory leak (not artificial latency/error) that grows until it hits the container's memory limit, enters a crash-loop (OOM-kill + automatic restart) under sustained load, and the order-service evolved, branch by branch, to handle it — retry with exponential backoff and jitter, circuit breaker, and bulkhead.

architecturecircuit-breakerresilience

Introduction

This is a resilience case study: the payment-service simulates a real memory leak that grows until it hits the container's memory limit, enters a crash-loop (OOM-kill + automatic restart) under sustained load, and the order-service evolved, branch by branch, to handle it: retry with exponential backoff and jitter, circuit breaker, and bulkhead.

Source Code and Load Reports: Full repository on GitHub with the 4 evolutionary branches, the k6 scripts, and Docker Compose instructions:
🔗 github.com/marcelo3macedo/node-microservices-resilience-patterns


The Problem

The starting point of this case study is the feature/01-base-service-unstable branch: k6 fires load requests against the order-service, which in turn calls the payment-service to confirm each order. The catch is that the payment-service is an unstable service, with a real memory leak built in on purpose. Every request it receives allocates and retains a buffer, so the process's RSS (physical memory) grows continuously under load until it hits the container's memory limit (256MB), and Docker kills the process with an OOM (exit code 137).

“payment-service RAM during the test (2 leak → OOM-kill → restart cycles)” is an interactive chart, available only in the full version of the article.

RAM climbs almost linearly to ~250MB, drops around t+31s (OOM-kill + restart), and climbs again until the end of the test.

The order-service simply forwards the call to the payment-service with a 3s timeout, with no protection whatsoever. The result under load: 286 requests, 16.43% error rate, p(95) of 3004.54ms, with the timeout being hit in almost 1 out of every 6 orders, concentrated exactly in the window where the payment-service's memory is at its limit.


Solution Architecture

Initial situation:

This is an interactive diagram, available only in the full version of the article.

Final architecture:

This is an interactive diagram, available only in the full version of the article.

The order-service is the gateway that calls the payment-service synchronously over HTTP. Across four branches, it gained layers of protection:

BranchWhat was added
feature/01-base-service-unstableBase scenario: payment-service with a real memory leak, no protection whatsoever
feature/02-pattern-retry-backoffRetry with exponential backoff + jitter, and restart: on-failure on payment-service
feature/03-pattern-circuit-breakerCircuit breaker: fails fast when payment-service is degraded
feature/04-pattern-bulkhead-isolationBulkhead: caps concurrent calls, isolating the event loop

Decision Analysis

Detailed information on the main decisions in this case study:

DecisionAlternative ConsideredWhy It Was ChosenTrade-off Accepted
Synchronous HTTP Communication (with Retry + Circuit Breaker + Bulkhead)Asynchronous Messaging (SQS/RabbitMQ + Outbox Pattern)Isolate and measure resilience patterns in synchronous flows, without the eventual-consistency layer that queues add.No 100% delivery guarantee. Unavailability produces a fast failure (fail-fast) instead of deferred reprocessing.
Circuit Breaker window set to 1500ms (reduced from the default 3000ms)Keep the default 3000ms windowThe payment-service container recovers in ~1-2s after a restart. The shorter window transitions to Half-Open faster.Slight risk of firing a test request (Half-Open) while the target service is still finishing startup.
Retry limited to 3 attempts (1000ms backoff cap)More attempts / higher backoff capAvoid holding the client in the Event Loop for too long during Crash-Loop scenarios in the service.Requests that would have recovered on a 4th attempt fail early.

When applying the Circuit Breaker, why did the error rate rise from 2.58% to 7.84%?

Looking at the data in the Metrics and Results section, the absolute error rate increased from the earlier branches (2.58% in retry+backoff) to the more advanced ones (7.84% in circuit-breaker and 7.00% in bulkhead).

This is not a regression, but rather the expected behavior of the Circuit Breaker and Bulkhead patterns:

In high-availability systems, failing fast to protect the overall health of the cluster is preferable to trying to save individual requests at any cost.


Practical Implementation

Retry with exponential backoff and jitter

Jitter prevents multiple requests from retrying at the exact same instant against a service that just came back up:

js
function backoffDelay(attempt, baseDelayMs, maxDelayMs) {
  const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
  return Math.random() * exponential;
}

if (response.ok || !isRetryableStatus(response.status) || isLastAttempt) {
  return response;
}
const delay = backoffDelay(attempt, baseDelayMs, maxDelayMs);
await sleep(delay);

Circuit breaker

The CLOSED → OPEN → HALF_OPEN state machine is the "magic" of the pattern: while open, canAttempt() cuts off the call without even touching the payment-service:

js
canAttempt() {
  if (this.state !== STATE.OPEN) return true;
  if (Date.now() - this.openedAt >= this.openDurationMs) {
    this.state = STATE.HALF_OPEN;
    return true;
  }
  return false;
}

onFailure() {
  this.failureCount += 1;
  const shouldOpen = this.state === STATE.HALF_OPEN || this.failureCount >= this.failureThreshold;
  if (shouldOpen) {
    this.state = STATE.OPEN;
    this.openedAt = Date.now();
    this.failureCount = 0;
  }
}

Bulkhead

A simple counter of active calls; with no queue configured, the excess is rejected immediately instead of waiting:

js
async run(fn) {
  if (this.active >= this.maxConcurrent) {
    if (this.queue.length >= this.maxQueue) {
      throw new BulkheadRejectedError(
        `Bulkhead "${this.name}" full (${this.active}/${this.maxConcurrent} running, ${this.queue.length}/${this.maxQueue} queued)`
      );
    }
    await new Promise((resolve) => this.queue.push(resolve));
  }
  this.active += 1;
  try { return await fn(); } finally { this.active -= 1; }
}

Metrics, Results, and Lessons Learned

Numbers for each run (one per branch) are detailed in each branch's results/report.txt and results/k6-summary.json files.

BranchRequests (req/s)Error %p50p90p95Max
Base286 (8.16)16.43%33.11ms3002.77ms3004.54ms3034.73ms
Retry388 (11.03)2.58%16.70ms467.80ms1148.36ms2432.52ms
Circuit Breaker459 (13.00)7.84%11.73ms196.96ms399.79ms1370.63ms
Bulkhead443 (12.56)7.00%16.08ms105.03ms204.89ms2561.69ms

“Latency (ms) by percentile, per branch” is an interactive chart, available only in the full version of the article.

“HTTP error rate (%) per branch” is an interactive chart, available only in the full version of the article.

“Confirmed vs. failed orders, per branch” is an interactive chart, available only in the full version of the article.

“Throughput (requests/s), per branch” is an interactive chart, available only in the full version of the article.

In the last branch, with all three patterns active at the same time, you can see exactly which mechanism intercepted each type of degradation:

“Resilience mechanisms triggered on branch 04 (by event)” is an interactive chart, available only in the full version of the article.


Observations

The full code, with the four branches, the frozen READMEs for each stage, and the raw reports from each run, is at: