---
title: 'Strangler Fig Pattern'
source: 'https://academia.sh/en/courses/resilience-patterns/strangler-fig-pattern'
course: 'Resilience and Reliability'
language: en
updated: '2026-08-23T07:01:30+00:00'
license: 'CC BY-SA 4.0'
---

# Strangler Fig Pattern

Migrating the old system one route at a time: moving eight routes in two different orders, the volume order carrying traffic 3.89 times faster while growing the dual-write load 1.52 times, the migration facade drawing a share from the timeout budget, and reversibility rising from one round to 3360 seconds the moment dual writes stop.

The previous five lessons built the parts of the new arrangement: the idempotency ledger, the
compensation chain, the claim check, the valet key, and the external configuration store. All of
them stand on a single assumption — that the shipment tracking and billing service consists of
nothing but this design. But the service was not born from scratch: the old system is still
standing, next to the new arrangement.

The **strangler fig** pattern is a migration style that moves the old system's routes one at a
time instead of replacing it in one shot, placing a routing layer in front of both systems. That
layer is the **migration facade**: it takes every request, checks whether that route has been
moved, and sends it to the old or new system. The measures are four: the number and order of
moved routes, the size of state held in both systems at once, the facade's cost on a
failure-free day, and the round it takes to revert a route.

## Order Is a State Decision

The service has eight routes and five state entities. When a route is moved, if a state entity it
touches is also used by routes that have not moved, that entity **splits**: it must be held in
two stores at once and written to both on every write. Split state is the migration's real cost,
and the migration order determines it directly.

**DD18 — one invoice read per seller a day**, so 4000 a day; the rationale is K01's 4000 sellers.
Tariff volume comes from the Scaling the Data Layer course's D5 assumption, proof volumes from
this topic's DD8 and DD12 assumptions; the rest is K01's computed value.

```js
// fig/migration.mjs — in-process model of moving routes one at a time. There is no real system,
// network, or store: routes and state entities are objects, split state is a set operation.

// [route name, daily request volume (K01 computed values and DD18), state read, state written]
export const ROUTES = [
  ["tracking-query", 12_000_000, "shipment event", ""],
  ["status-event", 2_800_000, "shipment", "event"],
  ["shipment-create", 400_000, "", "shipment"],
  ["proof-upload", 400_000, "event", "proof"],
  ["proof-read", 8_000, "proof", ""],
  ["billing-period", 4_000, "shipment event tariff", "invoice"],
  ["invoice-read", 4_000, "invoice", ""],
  ["tariff-admin", 1, "", "tariff"],
].map(([name, volume, r, w]) => ({ name, volume, touches: `${r} ${w}`.split(" ").filter(Boolean) }));

// Daily write volume of each state entity (K01 computed values; tariff is M19/K04's D5 assumption)
export const WRITES = { shipment: 400_000, event: 2_800_000, proof: 400_000, invoice: 4_000, tariff: 1 };
export const DAY = 86_400;

// Given a set of moved routes, the split state entities and the dual-write rate
export function split(moved) {
  const names = new Set(moved.map((r) => r.name)), remaining = ROUTES.filter((r) => !names.has(r.name));
  const b = Object.keys(WRITES).filter((d) => moved.some((r) => r.touches.includes(d)) && remaining.some((r) => r.touches.includes(d)));
  return { entities: b, dualWrite: b.reduce((t, d) => t + WRITES[d], 0) / DAY };
}

// Order: "volume" = the route with the most requests first; "state" = the route minimizing split writes first
export function sortRoutes(mode) {
  if (mode === "volume") return [...ROUTES].sort((a, b) => b.volume - a.volume);
  const remaining = [...ROUTES], order = [];
  while (remaining.length > 0) {
    let best = 0;
    for (let i = 1; i < remaining.length; i += 1) {
      const a = split([...order, remaining[i]]).dualWrite, e = split([...order, remaining[best]]).dualWrite;
      if (a < e || (a === e && remaining[i].volume > remaining[best].volume)) best = i;
    }
    order.push(...remaining.splice(best, 1));
  }
  return order;
}
```

```js
// fig/measure.mjs — dual-write load, moved traffic, revert, and the failing day for two orders
import { ROUTES, WRITES, DAY, split, sortRoutes } from "./migration.mjs";

const TOTAL = ROUTES.reduce((t, r) => t + r.volume, 0);
const EDGE_PEAK = 513.89, SCAN_RATE = 833.33, BUDGET = 28.2 * 60;   // K01 computed values; monthly failure share
console.log(`${ROUTES.length} routes, ${Object.keys(WRITES).length} state entities, ` +
  `${TOTAL.toLocaleString("en-US")} requests a day`);

const track = (mode) => {
  const moved = [], rows = [];
  let dualWriteArea = 0, trafficArea = 0;
  for (const r of sortRoutes(mode)) {
    moved.push(r);
    const b = split(moved), share = moved.reduce((t, x) => t + x.volume, 0) / TOTAL;
    dualWriteArea += b.dualWrite; trafficArea += share;
    rows.push([r.name, share, b.entities.length, b.dualWrite]);
  }
  return { rows, dualWriteArea, trafficArea };
};

const R = {};
for (const mode of ["volume", "state"]) {
  R[mode] = track(mode);
  console.log(`\n${mode} order`);
  console.log(`${"step".padStart(5)}${"moved route".padStart(18)}${"moved traffic".padStart(16)}${"split entities".padStart(17)}${"dual writes/s".padStart(16)}`);
  R[mode].rows.forEach(([name, share, n, c], i) => console.log(`${String(i + 1).padStart(5)}${name.padStart(18)}${`${(100 * share).toFixed(2)}%`.padStart(16)}${String(n).padStart(17)}${c.toFixed(2).padStart(16)}`));
  console.log(`dual-write area ${R[mode].dualWriteArea.toFixed(2)} writes/s-step, moved-traffic area ${R[mode].trafficArea.toFixed(4)} steps`);
}
console.log(`\nvolume / state: dual writes ${(R.volume.dualWriteArea / R.state.dualWriteArea).toFixed(2)}x, moved traffic ${(R.volume.trafficArea / R.state.trafficArea).toFixed(2)}x`);

// Revert: while dual writes are running, switching routing is 1 round; once they stop, backfill is needed.
console.log(`\n${"after dual writes stop".padEnd(31)}${"events to backfill".padStart(21)}${"revert s".padStart(11)}${"of failure share".padStart(19)}`);
for (const [label, afterSeconds] of [["0 (dual writes still running)", 0], ["1 hour", 3600], ["1 day", 86_400]]) {
  const records = (WRITES.event / DAY) * afterSeconds, seconds = records / SCAN_RATE;
  console.log(`${label.padEnd(31)}${Math.round(records).toLocaleString("en-US").padStart(21)}${seconds.toFixed(2).padStart(11)}${`${((100 * seconds) / BUDGET).toFixed(2)}%`.padStart(19)}`);
}

// Failing day: the new system stops the first step each order's moved traffic crosses 50 percent.
const DD20 = 60;                                  // routing entry lifetime in the facade-less arrangement, s
console.log(`\n${"order".padEnd(8)}${"step".padStart(6)}${"moved traffic".padStart(16)}${"facade: dropped".padStart(17)}${"DD20: dropped".padStart(15)}${"equiv. outage s".padStart(17)}${"of failure share".padStart(19)}`);
for (const mode of ["volume", "state"]) {
  const i = R[mode].rows.findIndex(([, p]) => p >= 0.5), share = R[mode].rows[i][1];
  const dropped = EDGE_PEAK * share * DD20;
  console.log(`${mode.padEnd(8)}${String(i + 1).padStart(6)}${`${(100 * share).toFixed(2)}%`.padStart(16)}${(EDGE_PEAK * share).toFixed(0).padStart(17)}${dropped.toFixed(0).padStart(15)}${(dropped / EDGE_PEAK).toFixed(2).padStart(17)}${`${((100 * dropped) / EDGE_PEAK / BUDGET).toFixed(2)}%`.padStart(19)}`);
}
```

```
8 routes, 5 state entities, 15,616,001 requests a day

volume order
 step       moved route   moved traffic   split entities   dual writes/s
    1    tracking-query          76.84%                2           37.04
    2      status-event          94.77%                2           37.04
    3   shipment-create          97.34%                2           37.04
    4      proof-upload          99.90%                3           41.67
    5        proof-read          99.95%                2           37.04
    6    billing-period          99.97%                2            0.05
    7      invoice-read         100.00%                1            0.00
    8      tariff-admin         100.00%                0            0.00
dual-write area 189.86 writes/s-step, moved-traffic area 7.6878 steps

state order
 step       moved route   moved traffic   split entities   dual writes/s
    1      tariff-admin           0.00%                1            0.00
    2      invoice-read           0.03%                2            0.05
    3   shipment-create           2.59%                3            4.68
    4        proof-read           2.64%                4            9.31
    5      proof-upload           5.20%                4           37.08
    6    billing-period           5.23%                2           37.04
    7    tracking-query          82.07%                2           37.04
    8      status-event         100.00%                0            0.00
dual-write area 125.19 writes/s-step, moved-traffic area 1.9775 steps

volume / state: dual writes 1.52x, moved traffic 3.89x

after dual writes stop            events to backfill   revert s   of failure share
0 (dual writes still running)                      0       0.00              0.00%
1 hour                                       116,667     140.00              8.27%
1 day                                      2,800,000    3360.01            198.58%

order     step   moved traffic  facade: dropped  DD20: dropped  equiv. outage s   of failure share
volume       1          76.84%              395          23694            46.11              2.72%
state        7          82.07%              422          25305            49.24              2.91%
```

These numbers belong to the **measurement** class; they come from an in-process model, and their
inputs are K01's computed values and this topic's assumptions.

## Two Orders, Two Different Things Improve

The volume order carries 76.84 percent of traffic in the first step. Its cost starts immediately:
`shipment` and `event` split from the first step onward, and dual writes hold at 37.04 writes/s
through the migration. That is an average rate, and because each split entity's write is applied
**again** to the second store, it doubles the write path exactly, to 74.08 writes/s.

The state order does the opposite: dual writes are near zero for the first six steps, but moved
traffic stays at 5.23 percent, and the old system carries its full load through most of the
migration. Two integrals give the trade-off in a single line — the volume order carries traffic
3.89 times faster, growing the dual-write load 1.52 times.

The split-entity count opens a third distinction: the state order lowers the dual-write **rate**
while raising the split-entity **count** to 4 (at most 3 under the volume order) — more code
paths, fewer bytes. **There is no single best migration order**; the choice is the answer to
whether the old system's load or the dual-write risk costs more.

## The Facade's Cost and Reversibility

The facade sits in front of all traffic, and its failure-free-day cost is three items. The first
is an **extra stop**: all 15,616,001 requests a day, 513.89 requests/s at peak edge, pass through
one more routing decision. The second is **a share of the timeout budget**. The Timeout Budgets
lesson split the 200-millisecond threshold: gateway share 20 ms, remaining arm 180 ms,
distribution 90/90. If the facade also takes a gateway-sized share (**DD19**), the remaining arm
drops to 160 ms and the distribution becomes 80/80 — each step's share narrows 11.1 percent. The
third is dual writes. The facade is also a new single point: the Deployment Stamps and
Geo-Replicas lesson measured the routing map's blast radius at 90.9 percent; the facade's is 100
percent.

What it buys in return is **reversibility**. The bottom table counts the case where the new
system stops mid-migration. With the facade in place, reverting a route is a single routing
decision change: 395 requests drop under the volume order, 422 under the state order. In an
arrangement where clients are routed directly, reverting takes as long as the routing entry's
lifetime (**DD20 — 60 seconds**), and dropped requests climb to 23,694 and 25,305: a
46–49-second equivalent outage, 2.72–2.91 percent of K01's monthly failure share.

Reversibility has an expiration date. While dual writes run, the old system's data stays current,
and reverting is one round. An hour after they stop, 116,667 events must be backfilled into the
old system: 140.00 seconds at K01's 833.33 records/s scan rate, 8.27 percent of the failure
share. A day later it is 2,800,000 events and 3360.01 seconds — 198.58 percent of the share.
**Stopping dual writes is not a savings decision, it is closing off the way back.**

## Summary

- If a moved route's touched state entity is also used by an unmoved route, it splits and is
  written to two stores at once; dual writes raise the write path from 37.04 to 74.08 writes/s.
- The volume order carries 76.84 percent of traffic in the first step but pushes dual writes to
  37.04 writes/s immediately; the state order gets through its first six steps with no dual
  writes, keeping traffic at 5.23 percent.
- Two integrals give the trade-off: the volume order carries traffic 3.89 times faster, growing
  the dual-write load 1.52 times. There is no single best order.
- The facade's failure-free-day cost: an extra stop on 15,616,001 requests a day, a
  gateway-sized share of the budget (arm share 180 → 160 ms, distribution 90/90 → 80/80), and a
  single point with 100 percent blast radius.
- The facade's failing-day gain is revert speed: 422 dropped requests instead of 25,305.
- Reverting is one round only while dual writes run; a day after they stop, it rises to 3360.01
  seconds (198.58 percent of the failure share).

## Course Wrap-Up

This course took the system four earlier courses designed and put it under failure, asking the
same two questions in every lesson: what does this pattern cost on a failure-free day, and how
many units does it rescue on a failing day.

| Lesson | Failure mode | Failure-free day's cost | Failing day's gain |
|---|---|---|---|
| Failure Modes | down, slowdown, partial failure, stale content, network partition | 8-byte stamp on every response (4111.12 bytes/s); 20-call window for naming | down 97.22 req/s and 1 component, slowdown 513.89 req/s and 2 components — 5.29x |
| Circuit Breaker | dependency slowdown and down | queried on all 1166 calls, 20-record window per dependency; ratio-0.10 threshold rejects 386 valid events on a healthy day | tracking requests finding no slot 671 → 0; event leg's slot-rounds 19,312 → 1646 (91.48% less) |
| Bulkhead Pattern | slowdown of one dependency | dropped requests 105 → 578 (5.5x), utilization 0.541 → 0.524; 4239 times rejected while an idle slot sits in the neighboring pool | neighboring flow's dropped requests 2654 → 172 (93.5%), independent of detection |
| Retries and Storm Risk | full outage and partial degradation | call count unchanged from allowance 1 to 27, multiplier 1.03 | retry budget 0.30 cuts the outage storm from 6.24 to 2.88 calls/round; cost 103 dropped requests |
| Timeout Budgets | dependency slowdown (AY10) | deadline field 40 bytes (8.3% of the response); 2 of 48 requests cut off | weighted split brings median 200 → 93 ms, held slots 82.4 → 40.2 (with cancellation signal) |
| Backpressure | overload (AY11) | 4164 valid events rejected (3.57%), 291.66 signal checks/s | 44,399 events shift from lost to rejected; at the narrow gap, loss falls 34,064 → 8579 (ratio 3.971) |
| Throttling and Load Shedding | batch job moves into peak hour (AY13a); edge node goes down (AY13b) | throttling rejects 15,630 valid queries (6.25%); shedding costs nothing | shedding protects 0.9348 of the peak under AY13b; the gateless arrangement would have eaten 56.7% of the failure share |
| Graceful Degradation | optional dependency (AY14a); core dependency (AY14b) goes down | 24-byte freshness marker (5.0%), 2.29 MiB fallback store, 1666.68 writes/s | AY14a cuts downtime 15.00 → 0.00 minutes; AY14b only 15.00 → 14.02 |
| Failover Design | active replica stops | 120 s/month false-failover outage at threshold 1; zero at threshold 3, real-stop window rising 4 → 8 s | outage 20.0 minutes → 16 seconds (75x); 0.95% of the failure share instead of 70.9% |
| Health Endpoint Monitoring | half-failed replica; shared dependency goes down | deep check adds 1.08% load to the store (17.28% at 48 replicas); false eviction raises load to 1.50x, 13.3 times a month | silent lost updates 17 → 0; readiness check saved 40 reads and 10 writes |
| Recovery Objectives | data-loss and recovery-time thresholds exceeded | zeroing out data loss costs 2,800,000 acknowledgment rounds a day, 1,555.52 rejected writes a month | 194.44 permanent losses a month disappear (ratio 8.00); the 8-second window fits 211.50 failures into the failure share |
| Redundancy Zones | machine, zone, and region loss | idle capacity $1/(n-1)$: 100% with two zones, 20% with six zones; the two-region arrangement loses 1 write even at a single machine's loss | zone loss: dropped writes 126 → 0, lost 114 → 2 |
| Deployment Stamps and Geo-Replicas | in-stamp component failure | components 9 → 33 (65 with geo-replicas); end-of-day billing must reach as many places as there are stamps | state store blast radius 100% → 25%; links crossing the stamp 6 → 2 |
| Disaster Recovery Drill | controlled failure trial | a monthly drill costs 0.63 minutes (4.22% of the planned share); drops 19,527.82 edge requests at peak load | plan's 6-round recovery measured 19 rounds in the drill (3.17x); the 30 s objective breaks at 38 s |
| Idempotent Operations | duplicate request under failover and retry | ledger 3,200,000 records / 230.40 MB (23.61% of daily growth); write rows ×2.00, reads ×3.33 | 8002 prevented side effects a month: 902 duplicate invoice lines, 902 duplicate notifications |
| Compensating Transactions | workflow interrupted midway | 8000 written compensation paths, 60 calls run a day; uncompensatable step cannot be moved to the end | uncompensated step 80 → 60/day; second attempt drops stuck work 2.979 → 0.171/day |
| Claim Check Pattern | producer and consumer crashes; queue backlog | 112.00 GB in the store (15.72% of retention); 1968 orphaned payloads a day, 3948 dangling claim checks | queue 4.4689 → 0.0316 Mbit/s; backlog 3016.5 → 21.3 MB (141.6x) |
| Valet Key Pattern | proof store slowdown | 400,000 signings a day (2.703% of K01's edge peak); 4166.7-key open window | dropped transfers 28 → 0, held slots 64 → 0; scope narrowing 730x |
| External Configuration Store | store goes down; wrong value written | store reads 0.1000/s; propagation 25 s, mixed-value window 40 s | dropped requests 20,556 → 0 (2.36% of the failure share); staged rollout drops faulty responses 61,668 → 20,556 |
| Strangler Fig Pattern | new system stops mid-migration | dual writes push the write path 37.04 → 74.08 writes/s; the facade takes 20 ms from the budget (180 → 160 ms) | dropped requests 25,305 → 422; revert is 1 round with dual writes, 3360.01 s a day after they stop |

The table's rule deserves its own name: **a pattern cannot be defended without two numbers.** The
sentence "it improves resilience" carried no decision in this course. The two columns are not
interchangeable either — the failure-free day's cost is paid every day, the failing day's gain is
collected a few times a year; choosing a pattern means comparing the product of these two
frequencies. The course's own assumptions (the AY, KU, and DD series) never mixed into K01's
table; each was written under its own name and sensitivity.

The question the course leaves behind sits underneath the whole table. The circuit breaker works
once a threshold is **noticed** as crossed, failover once a replica is noticed to have stopped,
backpressure once a queue is noticed to be filling, the external configuration store once a value
is noticed to be wrong. How the noticing happens was never designed: which metric gets collected,
which threshold raises an alert, which measurement makes a slowdown visible, and how the load a
design can carry gets tested were all assumed. The next course, **Performance Anti-Patterns and
Monitoring**, takes on that layer.
