Anatomy of a Cascading Outage: Memory Leaks, Connection Pool Exhaustion, and Deadlock Cascades
"At 03:14 UTC, a Tier-1 ledger service processing 22,000 transactions per second ($TPS$) experienced systemic failure. Over twelve minutes, edge gateway 504 sta"
At 03:14 UTC, a Tier-1 ledger service processing 22,000 transactions per second () experienced systemic failure. Over twelve minutes, edge gateway 504 status codes escalated from 0.01% to 94.2%, and database commit throughput dropped to zero.
The incident was not caused by external traffic spikes or hardware failure. It was an architectural cascade: an un-evicted closure in a Node.js telemetry hook initiated garbage collection pauses, which exhausted database connection pools in accordance with Little's Law. This saturated pool queued in-flight requests, triggering retry amplification and culminating in distributed database deadlocks on the underlying relational store.
Incident Timeline
03:14 UTC - V8 GC STW pauses reach 950ms; application p99 latency degrades from 18ms to 1,200ms.
03:17 UTC - Database connection pools across all 48 container replicas reach 100% saturation.
03:18 UTC - Edge ingress timeouts trip upstream circuit breakers; clients initiate unthrottled retries.
03:20 UTC - PostgreSQL logs widespread deadlock exceptions on the `accounts_ledger` table.
03:22 UTC - Primary Aurora instance hits 100% CPU due to lock manager lwlock contention.
03:26 UTC - Incident commander sheds 90% of ingress traffic; stateful pods drained and recycled.
Phase 1: V8 Heap Exhaustion and Event Loop Starvation
The root failure was introduced two deployments prior via an internal telemetry wrapper. To log asynchronous context alongside failed outbound network requests, the module attached an error listener to a process-wide singleton emitter on every execution path:
// Problematic instrumentation wrapper
export class TransactionTracer {
private static registry = new EventEmitter();
public static trace(txId: string, context: Record<string, unknown>) {
// BUG: Closure retains 'context' and 'txId' in the global EventEmitter's internal array
this.registry.on('error', (err) => {
metricsClient.increment('tx.failed', { txId, ...context, error: err.message });
});
}
}
Because EventEmitter.prototype.on appends callbacks to an internal array without lifecycle management or deduplication, references to the enclosing scope (txId and context) remained pinned in V8's Old Space.
As heap allocation approached the container boundary, the V8 runtime transitioned from lightweight Scavenge passes to Full Mark-Sweep-Compact cycles. Stop-The-World (STW) pauses climbed from a baseline of to over :
# Node.js trace-gc output during incident
[1:0x55d78a0] 452104 ms: Mark-sweep 1382.4 (1420.1) -> 1378.1 (1420.1) MB, 948.2 / 0.0 ms (average mu = 0.120) allocation failure GC in old space requested
While the main thread was suspended for garbage collection, the libuv event loop was blocked from processing I/O callbacks, polling OS socket buffers, or advancing Promise resolutions.
Phase 2: Connection Pool Saturation and Little's Law
When the JavaScript runtime stalled, connections checked out from the PostgreSQL pool were held open by blocked tasks. Because clients could neither execute queries nor release their sessions back to the pool, the active connection count surged.
This saturation behavior is quantified by Little’s Law:
Where:
- is the number of active, in-flight database connections.
- is the incoming transactional arrival rate ().
- is the total session hold time, encompassing query execution, network round-trips, and application-side GC latency.
Under steady-state operations:
With our pool capacity configured to , the service had ample headroom. However, as V8 GC pauses inflated transaction hold times to :
Because , available pool capacity was consumed immediately across all 48 replicas:
{
"pool": "PostgresPool-Ledger",
"total_connections": 30,
"idle_connections": 0,
"active_connections": 30,
"pending_requests": 1420,
"acquire_timeout_ms": 5000
}
As pending acquisition queues exceeded capacity, requests failed with connection acquisition timeouts. Upstream ingress proxies treated these drops as transient network failures and initiated exponential retries without jitter, inflating arrival rate by a factor of 3.
Phase 3: Wait-For Graph Cycles and Deadlock Cascades
Transactions that successfully acquired a database connection were subjected to arbitrary pauses while holding row-level locks. Ledger transfers required balance adjustments across two accounts, executed in arbitrary order based on runtime object iteration:
-- In-flight Transaction A (Backend 101)
BEGIN;
UPDATE accounts_ledger SET balance = balance - 100.00 WHERE id = 'acc_uuid_8a9f'; -- Acquires RowExclusiveLock on Row 1
-- Main thread stalls due to 950ms GC pause...
UPDATE accounts_ledger SET balance = balance + 100.00 WHERE id = 'acc_uuid_3b2c'; -- Blocked: waits for Row 2
-- In-flight Transaction B (Backend 204)
BEGIN;
UPDATE accounts_ledger SET balance = balance - 50.00 WHERE id = 'acc_uuid_3b2c'; -- Acquires RowExclusiveLock on Row 2
-- Main thread stalls due to 950ms GC pause...
UPDATE accounts_ledger SET balance = balance + 50.00 WHERE id = 'acc_uuid_8a9f'; -- Blocked: waits for Row 1
A deadlock occurs when a directed cycle forms within the database Wait-For Graph , where vertices represent active transactions and directed edges represent lock dependencies:
[Transaction A] --- holds Lock 1 ---> (Row: acc_uuid_8a9f) <--- waits for Lock 1 --- [Transaction B]
| ^
| |
+--- waits for Lock 2 ------> (Row: acc_uuid_3b2c) --- holds Lock 2 -----------------+
PostgreSQL resolves deadlocks by running a cycle-detection routine across the lock tree when a transaction remains blocked for longer than deadlock_timeout (configured at ). As thousands of stalled connections held locks, thousands of concurrent backends invoked cycle detection simultaneously.
The resulting contention over the PostgreSQL lock manager's shared memory hash tables (LockHashPartitionLock lightweight locks) drove database CPU utilization to 100%. The database engine spent its processing capacity arbitrating lock queues and aborting transactions rather than processing I/O.
Phase 4: Observability Redesign: Multi-Window Burn Rates and Exemplars
During the collapse, 187 alert conditions fired across PagerDuty. Metrics monitoring CPU load, connection counts, GC duration, and HTTP failure rates generated diagnostic noise that concealed the root failure sequence.
We replaced static threshold alerts with Multi-Window Multi-Burn-Rate Alerts focused on Service Level Objectives (SLOs).
High-Signal Burn-Rate Configuration
Alerts now trigger strictly when the consumption rate of our quarterly error budget indicates an active, critical incident:
# Prometheus Alert Rule: Ledger Error Budget Depletion
- alert: LedgerAvailabilityRapidBurn
expr: |
(
sum(rate(http_requests_total{service="ledger", status=~"5.."}[1h]))
/
sum(rate(http_requests_total{service="ledger"}[1h]))
) > (14.4 * (1 - 0.9995))
and
(
sum(rate(http_requests_total{service="ledger", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="ledger"}[5m]))
) > (14.4 * (1 - 0.9995))
for: 2m
labels:
severity: page
tier: critical
annotations:
summary: "High burn rate (14.4x) consuming 2% of 30-day budget in 1 hour."
runbook_url: "https://internal.wiki/runbooks/ledger-outage"
Trace Exemplar Correlation
To bypass manual log aggregation across disparate systems, metrics now link directly to traces via OpenTelemetry exemplars:
Telemetry Signal Flow:
[Incoming Request]
│
├─► OpenTelemetry SDK (Span Context Injection)
│ └─ Tags: Tenant, Route, Lexicographical Lock Sequence
│
├─► Prometheus Collector (SLO Burn Rates)
│ └─ Attaches Exemplar ID to high-latency histogram buckets
│
└─► Jaeger / ClickHouse Storage
└─ Trace indexed directly by Exemplar ID for immediate root-cause isolation
Architectural Remediation and Guardrails
To address each phase of the failure chain, we deployed the following technical controls:
Deterministic Lock Ordering: All transactions mutating multiple rows must sort their targeted identifier keys lexicographically prior to issuing queries. Enforcing an identical locking sequence guarantees that the Wait-For Graph remains a Directed Acyclic Graph (), rendering deadlock cycles mathematically impossible:
typescriptconst sortedAccountIds = [...accountIds].sort((a, b) => a.localeCompare(b));Bounded Pool Fail-Fast Mechanics: We restricted application pool sizes and bounded query execution times to protect the database against queue inflation:
typescriptconst poolConfig = { max: 20, connectionTimeoutMillis: 1500, // Fail fast before edge gateway times out (2000ms) idleTimeoutMillis: 10000, statement_timeout: 2000 // Abort queries before holding locks across slow operations };Node.js Memory Enforcement: All services run with
--max-old-space-size=1024and--diagnostic-report-on-uncaught-exception. We integrated automated heap inspection into our integration test pipelines to flag listener count increases on EventEmitter singletons across sustained benchmark loops.
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.