INCIDENT - RESOLVED
Postmortem · Infrastructure

HOW A MISCONFIGURED LOAD BALANCER TOOK DOWN OUR APPLICATION

A single integer value in a health check timeout setting cascaded into a 47-minute full outage affecting 220,000 users. This is the full story — what happened, why it happened, and what we changed so it never happens again.

47
minutes
total downtime
220K
users
affected
100%
traffic
failing at peak
1
config line
root cause

The Setup

Our application runs across three availability zones behind an AWS Application Load Balancer (ALB). At the time of the incident, we were handling approximately 3,400 requests per second at peak, distributed across 18 backend EC2 instances in an auto-scaling group.

The platform team had been in the middle of a routine infrastructure hardening initiative — tightening health check parameters, reducing idle connection timeouts, and enforcing stricter SSL policies across all load balancers. Tickets had been open for weeks. It was a Tuesday afternoon maintenance window. Everything seemed low-risk.

It was not low-risk.

⚙ Infrastructure Context

Stack: AWS ALB → Auto Scaling Group (18 × m5.large) → Node.js API (Express) → RDS PostgreSQL + ElastiCache Redis. Each instance served both API and WebSocket connections. Health checks were the only mechanism ALB used to determine instance availability.


What Actually Happened

At 14:32 UTC, an engineer applied a Terraform plan to update the ALB target group health check configuration. The change was intended to reduce the health check interval from 30 seconds to 10 seconds — a sensible tightening to speed up unhealthy instance detection.

But bundled in the same Terraform apply, almost unnoticed, was a second parameter change: the health check timeout was reduced from 5 seconds to 2 seconds.

Our Node.js /health endpoint — under normal load — typically responds in 80–140ms. But under the load our application was experiencing at 14:32 UTC, with a cold database connection pool warming up after a scheduled maintenance restart 20 minutes earlier, the health endpoint was occasionally taking 2.1 to 2.8 seconds to respond.

A timeout of 2 seconds meant those responses were being seen as failures by the ALB — even though the instances were perfectly healthy.

The Error Logs at 14:34 UTC

alb-access-logs · us-east-1 · 2025-03-12
14:32:01 INFO Terraform apply complete. 2 resources changed. ALB target group updated.
14:32:45 WARN Health check timeout on i-0a3f2b1c9d8e7f654: response_time=2143ms threshold=2000ms
14:33:10 ERROR Instance i-0a3f2b1c9d8e7f654 marked unhealthy (3/3 consecutive failures)
14:33:12 ERROR Instance i-0b4c3d2e1f0a9876 marked unhealthy (3/3 consecutive failures)
14:33:15 WARN Auto Scaling launching replacement instances. Current healthy: 14/18
14:34:02 ERROR Cascade: 8 instances now unhealthy. New instances under load → health checks also timing out
14:34:38 CRITICAL ALB target group: 0 healthy targets. All traffic returning 503 Service Unavailable.
14:35:01 CRITICAL PagerDuty alert fired. Error rate: 100%. On-call engineer paged.

The Cascade — Why It Spread So Fast

The insidious part was the feedback loop. As instances were marked unhealthy and removed from the target group, the remaining healthy instances absorbed more traffic. Under increased load, their /health endpoints also started responding slowly — breaching the 2-second timeout threshold. They too were marked unhealthy.

Auto Scaling tried to help. It launched new instances. But new instances, cold-starting with a fresh database connection pool, had worse health check latency than the running instances — and were immediately marked unhealthy before they could even serve traffic.

Within 2 minutes and 37 seconds of the first timeout, all 18 instances were marked unhealthy. The ALB had zero healthy targets and returned 503 to every request.

🔥 The Compounding Factor

The health check endpoint was querying the database to verify connectivity — a common pattern that turns a lightweight health check into a load-bearing database query. Under stress, when you most need the health check to pass, database latency was exactly what caused it to fail. Never let your health check depend on the very resources that might be under strain.


Minute-by-Minute Incident Timeline


The Exact Configuration That Broke Us

Here is the precise Terraform diff that caused the incident. The interval change was the intended change. The timeout change was the silent killer — it looked like a routine tightening but created an impossible constraint for our application under load.

aws_lb_target_group · health_check block — broken + fixed (post-incident)
resource "aws_lb_target_group" "api" {
health_check {
path = "/health"
protocol = "HTTP"
- interval = 30
+ interval = 10
- timeout = 5 # was 5s — adequate headroom
+ timeout = 10 # post-incident: 10s = safe floor
healthy_threshold = 2
unhealthy_threshold = 3
matcher = "200"
}
}

The broken timeout of 2 seconds was actually lower than the p99 response time of the health endpoint under peak load. This meant that during the highest-traffic periods — exactly when reliability mattered most — health checks were structurally guaranteed to fail.

The Correct Health Check Endpoint (Post-Incident)

healthcheck.js · Node.js ✓ FIXED
// AFTER: Lightweight health check — no DB dependency
// Responds in <5ms under any load condition

app.get('/health', (req, res) => {
  // Check only in-process state — never external dependencies
  const status = {
    status:   'ok',
    uptime:   process.uptime(),
    memory:   process.memoryUsage().heapUsed,
    version:  process.env.APP_VERSION
  };

  res.status(200).json(status);
});

// Separate readiness endpoint for deep checks (not used by ALB)
app.get('/ready', async (req, res) => {
  try {
    await db.query('SELECT 1');
    await redis.ping();
    res.status(200).json({ ready: true });
  } catch (err) {
    res.status(503).json({ ready: false, error: err.message });
  }
});
✓ Key Principle: Health vs Readiness

Separate your liveness check (is the process alive?) from your readiness check (is the process ready to serve traffic?). ALBs should only use liveness — a simple, dependency-free endpoint that returns 200 in under 10ms regardless of load. Deep checks belong in readiness probes, not in load balancer health checks.


Lessons Learned

Six Things We Got Wrong

01

Health check timeout < P99 latency

We never benchmarked our health endpoint under peak load before setting the timeout. The timeout must always have headroom above your worst-case health check latency, not your average.

02

Health check queried the database

Our /health endpoint called SELECT 1 to verify DB connectivity. Under load, this made our health check dependent on the exact resource most likely to be slow.

03

No Terraform plan review for infra params

The timeout change was bundled with an interval change and never flagged in PR review. We had no linting or policy rules to catch health check parameter changes.

04

Auto Scaling amplified the failure

New instances launched cold, with worse health check latency than existing ones, and were immediately killed. Our Auto Scaling policy had no protection against this spiral.

05

No canary deployment for infra changes

The ALB change applied to all 18 instances simultaneously. A single target group or staged rollout would have contained the blast radius to a subset of traffic.

06

Alert linked to app errors, not ALB health

Our on-call alert fired on HTTP 5xx rate — which gave the engineer no direct signal that the ALB was the source. We lost 4 minutes investigating app logs.


Prevention

What We Changed After the Incident

📋 The Blameless Principle

This postmortem follows the blameless culture principle. The engineer who applied the Terraform change was following a legitimate process and acting in good faith. The failure was systemic — our tooling, review process, and alerting did not provide sufficient guardrails. We fix systems, not people.


Closing Thoughts

The One-Line Takeaway

A 47-minute outage affecting 220,000 users came down to a single number: 2 seconds instead of 5. Not a complex distributed systems failure. Not a novel attack. A field in a config file that nobody thought to stress-test under realistic conditions.

The lesson isn't to be more careful with config changes — it's to build systems where a single config change cannot cause a full outage. Guardrails, policy enforcement, staged rollouts, blameless culture, and deep observability are the layers that transform a catastrophic incident into a minor blip.

We've made all of those changes. The next misconfiguration will hit a guardrail long before it hits your users.

📝 Incident Metrics Summary

Time to detect: 3 min · Time to identify root cause: 7 min · Time to remediate: 10 min · Time to full recovery: 47 min · Users affected: ~220,000 · Follow-up action items completed: 6/6 within 2 sprints.