On a Tuesday morning about 3,100 customers received a second copy of their monthly invoice, and roughly 400 of them were charged twice by direct debit. The first root cause analysis was finished by lunchtime. It said: "An engineer manually re-ran the billing job after it appeared to fail. Action item: remind engineers not to re-run billing jobs." Everyone involved knew this was unsatisfying, and the same team had a different double-charge incident two months later.

We reran the analysis properly, and the difference was not effort but method. This post covers the three techniques we now combine, using that incident as the worked example: branching 5 whys, a fishbone diagram for breadth, and a cause-and-effect chart that ties evidence to every cause. How to write and share the postmortem document itself is a separate topic; this is about the analysis that goes into it.

What "root cause" should mean

Complex system failures rarely have a single root cause. They happen when several conditions line up, and each one alone was survivable. The useful goal of RCA is to find the set of causes that, if any one had been different, would have prevented or contained the incident, and to pick changes that remove the most of them cheaply.

Two rules follow:

  • "Human error" is a starting point, not a conclusion. When a person's action is in the chain, the next question is what made that action reasonable at the time.
  • Every cause needs evidence. A log line, a config diff, a metric, a timestamped message. A cause nobody can show is a hypothesis.

Step 1: a timeline before any whys

We build the timeline first, from logs, deploy history and chat, before anyone proposes causes. Analysis done without a timeline tends to follow whoever speaks first.

Time (UTC) Event Source
02:00 Scheduled billing job starts on worker A Scheduler log
02:41 Worker A loses connection to the job dashboard's status store; dashboard shows "failed" Dashboard error log
02:41 to 03:55 Worker A continues and completes 100% of invoices Job log, DB row counts
06:12 On-call sees "failed" on the dashboard, alert from 02:45 still open Alert history
06:20 On-call follows runbook "Billing job failed: re-run", starts job manually Shell history, runbook
06:20 to 07:30 Second run creates new invoices and debit requests for all customers DB, payment provider log
08:05 Support reports duplicate invoices Ticket system
08:20 Payment batch for the second run cancelled; about 400 debits already submitted Provider dashboard

The timeline already shows something the first RCA missed: the engineer did exactly what the runbook said, based on what the dashboard said.

Step 2: 5 whys, but branching

The classic 5 whys asks "why" repeatedly down one chain. The weakness is that each "why" usually has more than one honest answer, and a single chain picks one and drops the rest. We force branching: at each level, list every answer supported by evidence, then continue down each branch.

Problem: ~3,100 customers invoiced twice, ~400 charged twice

Why were invoices created twice?
  A. The billing job ran twice for the same period.
  B. The job does not check whether an invoice already exists for the period.

Why did the job run twice?  (branch A)
  A1. On-call started a manual run at 06:20.
      Why? The dashboard showed "failed" and the runbook says re-run on failure.
        Why did the dashboard show "failed"? Status is written to a separate store;
          a lost connection to that store is recorded as job failure.
        Why does the runbook say re-run? It was written when the job was
          all-or-nothing in one transaction; that stopped being true a year ago.
  A2. Nothing prevented a second concurrent or repeated run.
      Why? No lock or run record keyed by billing period.

Why did the job not detect existing invoices?  (branch B)
  B1. Invoices are keyed by a generated id, with no unique constraint on
      (customer_id, billing_period).
      Why? The constraint was dropped during a migration to support
      mid-month plan changes, which create a second invoice per period.

Why were customers charged, not just invoiced?
  C1. Debit requests are sent as invoices are created, not after a review step.
  C2. The idempotency key sent to the payment provider was the invoice id,
      which was new for each duplicate invoice.

Branching 5 whys turned one cause into six. It also killed the original action item. "Remind engineers not to re-run" contradicts a runbook that tells them to.

Step 3: a fishbone for breadth

5 whys goes deep. A fishbone (Ishikawa) diagram goes wide, so you notice categories nobody thought to ask about. The classic manufacturing categories do not fit software well, so we use our own:

Category Question Findings in this incident
Code What did the software do or fail to guard against? No idempotency per period; idempotency key tied to a new id
Data Did schema, data shape or volume contribute? Unique constraint removed; mid-month invoices legitimise duplicates
Process and docs Did a runbook, review or checklist lead here? Runbook out of date; no review step before debits
Tooling and observability Did tools show the wrong thing, or nothing? Dashboard reported "failed" for a status-store error
Change Did a recent change contribute? Migration a year earlier, runbook not updated with it
People and context What made the action reasonable at the time? 06:00 page, alert open for 3.5 hours, runbook clear and simple
External Did a dependency behave unexpectedly? Payment provider correctly honoured new idempotency keys
Code ---------\      Data ----------\      Process/docs ---\
 no period lock \     no unique key  \     stale runbook    \
 key = invoice id \   mid-month plans \    no debit review    \
                   >------------------->--------------------->  DUPLICATE CHARGES
Tooling ---------/   Change ---------/     People/context ---/
 status error =   /  migration without /    early page, open /
 job failure     /   runbook update   /     alert, clear doc/

The fishbone added one cause the 5 whys missed: the alert had been open for over three hours, which pushed the on-call engineer to act quickly rather than investigate. That came from asking "what was the context" as its own category.

Step 4: a cause-and-effect chart with evidence

The final step joins everything into one chart where every effect has its causes and every cause has evidence. We borrowed the idea from reality charting (the Apollo method): for each effect, look for at least one action (something that happened) and at least one condition (something that already existed and allowed the action to matter). Incidents need both, and RCAs that only list actions end up blaming people.

EFFECT: customers charged twice
 |- action:    second run sent debit requests              [provider log 06:21-07:30]
 |- condition: debits sent without review step             [billing code, send_debits()]
 |- condition: idempotency key = invoice id                [request payloads]
 |
 EFFECT: second run created invoices
  |- action:    manual run at 06:20                         [shell history]
  |- condition: no unique (customer_id, period) constraint  [migration 2025_03_14]
  |- condition: no run record / lock per period             [job code]
  |
  EFFECT: on-call started manual run
   |- action:    dashboard showed "failed"                  [dashboard screenshot, log]
   |- condition: runbook says "re-run on failure"           [runbook v3]
   |- condition: alert open 3.5 h, page at 06:12            [alert history]
   |
   EFFECT: dashboard showed "failed"
    |- action:    status-store connection dropped 02:41     [dashboard error log]
    |- condition: status write error treated as job failure [dashboard code]

Each line with evidence in brackets was verified in the RCA meeting. Two proposed causes were dropped because nobody could produce evidence for them.

Step 5: action items that change conditions

With the chart, choosing actions becomes a matter of picking conditions to remove. We rate each candidate by how many failure paths it closes and how much it costs.

Action Condition removed Type Owner
Add run ledger table keyed by billing period; job refuses a second completed run No lock per period Prevent Billing team
Idempotency key = customer id + billing period + invoice type Key tied to new id Prevent Billing team
Restore uniqueness with invoice type in the key: (customer_id, period, type) No unique constraint Prevent Billing team
Dashboard reports "status unknown" on store errors, with job log link Status error shown as failure Detect Platform team
Runbook: check job log and invoice counts before re-run; re-run is a no-op by design Stale runbook Mitigate Billing on-call
Debits for a run go out after an automated count check against the previous month No review step Contain Billing team
Alert escalation if a billing alert is unacknowledged for 30 minutes Alert open 3.5 h Detect SRE

We avoid action items of the form "be more careful", "add more tests" or "improve monitoring" without a specific condition attached. If an action does not remove or weaken a condition on the chart, it does not go in.

Running the session

  • Facilitator is not the incident commander and not from the owning team, so they can ask naive questions.
  • Timeline circulated a day early, so the meeting is spent on causes, not reconstructing events.
  • Language rule: describe what the system did and what information a person had, not what they "should have" done.
  • Time box: 90 minutes for a medium incident. If the chart is not finished, schedule a second session rather than rushing to action items.
  • Follow-up: action items get ticket links and due dates; the RCA is reopened if the same chart node causes another incident.

Common mistakes

Mistake Why it hurts Do this instead
Single-chain 5 whys Picks one cause, hides the rest Branch at every level with evidence
Stopping at "human error" Fixes nothing, discourages reporting Ask what made the action reasonable
Causes without evidence Folklore becomes the official story Attach a log, diff or metric to each node
Only actions, no conditions Ends in blame Find at least one condition per effect
Vague action items Never done, never measured Tie each action to a chart condition

Key takeaways

  • Build the timeline from evidence before anyone proposes causes.
  • Use branching 5 whys for depth and a software-specific fishbone for breadth.
  • Chart each effect with both actions and conditions, and require evidence for every node.
  • "Human error" starts the analysis; the runbook, dashboard or alert that shaped the decision is usually the real cause.
  • Choose action items by which conditions they remove, preferring changes that make the risky action safe by design.

References