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.
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 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).
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.
Initial situation:
Final architecture:
The order-service is the gateway that calls the payment-service synchronously over HTTP. Across four branches, it gained layers of protection:
| Branch | What was added |
|---|---|
feature/01-base-service-unstable | Base scenario: payment-service with a real memory leak, no protection whatsoever |
feature/02-pattern-retry-backoff | Retry with exponential backoff + jitter, and restart: on-failure on payment-service |
feature/03-pattern-circuit-breaker | Circuit breaker: fails fast when payment-service is degraded |
feature/04-pattern-bulkhead-isolation | Bulkhead: caps concurrent calls, isolating the event loop |
Detailed information on the main decisions in this case study:
| Decision | Alternative Considered | Why It Was Chosen | Trade-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 window | The 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 cap | Avoid 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:
order-service's resources), the architecture opts to reject calls quickly.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.
order-service/utils/retry.jsfeature/02-pattern-retry-backoffJitter prevents multiple requests from retrying at the exact same instant against a service that just came back up:
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);
order-service/utils/circuit-breaker.jsfeature/03-pattern-circuit-breakerThe 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:
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;
}
}
order-service/utils/bulkhead.jsfeature/04-pattern-bulkhead-isolationA simple counter of active calls; with no queue configured, the excess is rejected immediately instead of waiting:
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; }
}
Numbers for each run (one per branch) are detailed in each branch's results/report.txt and results/k6-summary.json files.
| Branch | Requests (req/s) | Error % | p50 | p90 | p95 | Max |
|---|---|---|---|---|---|---|
| Base | 286 (8.16) | 16.43% | 33.11ms | 3002.77ms | 3004.54ms | 3034.73ms |
| Retry | 388 (11.03) | 2.58% | 16.70ms | 467.80ms | 1148.36ms | 2432.52ms |
| Circuit Breaker | 459 (13.00) | 7.84% | 11.73ms | 196.96ms | 399.79ms | 1370.63ms |
| Bulkhead | 443 (12.56) | 7.00% | 16.08ms | 105.03ms | 204.89ms | 2561.69ms |
In the last branch, with all three patterns active at the same time, you can see exactly which mechanism intercepted each type of degradation:
payment-service. Retry alone wouldn't save a sustained outage.payment-service happens to be in a bad moment of its memory cycle.payment-service mid-memory-bottleneck cycle):
503 error.payment-service outage — that would require an asynchronous layer (queue/outbox), outside the scope of this case study.The full code, with the four branches, the frozen READMEs for each stage, and the raw reports from each run, is at: