<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vahid Aghajani</title>
    <description>The latest articles on DEV Community by Vahid Aghajani (@vahid_aghajani_60ce9dbec9).</description>
    <link>https://dev.to/vahid_aghajani_60ce9dbec9</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4015358%2F35ccb2f9-355f-4af6-a004-19ae755a9d8c.png</url>
      <title>DEV Community: Vahid Aghajani</title>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vahid_aghajani_60ce9dbec9"/>
    <language>en</language>
    <item>
      <title>Docker Compose vs an Orchestrator — Compose Is a Command, an Orchestrator Is a Loop</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 12 Oct 2026 15:13:58 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/docker-compose-vs-an-orchestrator-compose-is-a-command-an-orchestrator-is-a-loop-12do</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/docker-compose-vs-an-orchestrator-compose-is-a-command-an-orchestrator-is-a-loop-12do</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/wTKojMOXBk8" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/docker-compose-vs-an-orchestrator-explained-in-detail?id=234" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Docker Compose versus an orchestrator. Both start your containers from one file, so many people think the orchestrator is only a bigger version of Compose. &lt;strong&gt;It is not.&lt;/strong&gt; Compose is a command. An orchestrator is a loop.&lt;/p&gt;

&lt;p&gt;This article follows one service that wants three copies, and one machine that dies at three in the morning. It reads one Compose file, one small Python model and one Kubernetes file, line by line. At the end you know what the restart line protects, what it cannot protect, and what an orchestrator costs you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The server dies at 03:00. Who starts your containers again?
&lt;/h2&gt;

&lt;p&gt;You run one service as &lt;strong&gt;three containers&lt;/strong&gt; on one server. A container is one packaged, running copy of your program.&lt;/p&gt;

&lt;p&gt;One Compose file describes all three copies, and one command starts them. For months this works.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;what happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;for months&lt;/td&gt;
&lt;td&gt;3 copies run on one server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03:00&lt;/td&gt;
&lt;td&gt;the server loses power&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03:00&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 copies become 0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;the file still says 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;But a file cannot do anything. And the command that read the file finished months ago.&lt;/p&gt;

&lt;p&gt;So here is the question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the machine dies, who starts your containers again?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers (and what it does not)
&lt;/h2&gt;

&lt;p&gt;The subject is DevOps: the work of running software on servers. This article covers one decision inside it: &lt;strong&gt;Compose on one machine, or an orchestrator on many machines.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;orchestrator&lt;/strong&gt; is a program that keeps your containers running across a group of machines.&lt;/p&gt;

&lt;p&gt;Left alone on purpose: &lt;strong&gt;what Docker and Kubernetes are, as two tools&lt;/strong&gt;, and &lt;strong&gt;container images&lt;/strong&gt;. Both have their own episodes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where does this live? It is built into your tools.&lt;/strong&gt; You will not write an orchestrator yourself. Kubernetes, Docker Swarm and Nomad are orchestrators, and they are already built. What you touch is small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a file that says how many copies you want;&lt;/li&gt;
&lt;li&gt;the number of machines;&lt;/li&gt;
&lt;li&gt;the failure you must plan for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This decision matters the first time one machine is not enough, or one machine is not safe enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  A light switch and a thermostat
&lt;/h2&gt;

&lt;p&gt;Think of two things on a wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first thing is a light switch.&lt;/strong&gt; You press it once, and the lamp turns on. After that, the switch does nothing. If the lamp breaks at night, the switch does not know, and the room stays dark until you come back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second thing is a thermostat.&lt;/strong&gt; You give it a target: twenty-one degrees. It measures the room again and again. When a window opens and the room gets cold, the thermostat turns the heater on. Nobody told it to do that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;a light switch&lt;/th&gt;
&lt;th&gt;a thermostat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;what you give it&lt;/td&gt;
&lt;td&gt;one press&lt;/td&gt;
&lt;td&gt;a target: 21 degrees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what it does after that&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;measures the room, again and again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;when something breaks at night&lt;/td&gt;
&lt;td&gt;the room stays dark until you come&lt;/td&gt;
&lt;td&gt;it turns the heater on by itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;in this article&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Compose: a command&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;an orchestrator: a loop&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Compose is the light switch. An orchestrator is the thermostat.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Compose file, line by line
&lt;/h2&gt;

&lt;p&gt;Here is the Compose file. It is real, and it is short.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/web:1.4&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

&lt;span class="c1"&gt;# then, on the one machine, you run:&lt;/span&gt;
&lt;span class="c1"&gt;#   docker compose up -d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The service is called &lt;code&gt;web&lt;/code&gt;.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;image&lt;/code&gt; line&lt;/strong&gt; says which packaged program to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;restart&lt;/code&gt; line&lt;/strong&gt; tells the Docker engine to start a container again if that container stops. The &lt;strong&gt;Docker engine&lt;/strong&gt; is the program on the machine that runs containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;replicas&lt;/code&gt; line says three.&lt;/strong&gt; A replica is one running copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now you run one command: &lt;code&gt;docker compose up&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;what the command does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;reads this file, &lt;strong&gt;once&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;asks the engine on &lt;strong&gt;this one machine&lt;/strong&gt; for three containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;is finished&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The whole system, drawn once
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The file&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one YAML file, one number&lt;/td&gt;
&lt;td&gt;holds the number you want: 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The command&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker compose up&lt;/code&gt;, one process&lt;/td&gt;
&lt;td&gt;reads the file once, starts 3 copies, then exits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 server, 1 container engine&lt;/td&gt;
&lt;td&gt;runs the copies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;strong&gt;node&lt;/strong&gt; is one machine that runs containers.&lt;/p&gt;

&lt;p&gt;Two arrows join the parts. The file goes to the command: &lt;strong&gt;read once&lt;/strong&gt;. The command goes to node A: &lt;strong&gt;start 3&lt;/strong&gt;. Both arrows point one way only. Nothing flows back, and after the command exits nothing runs in the middle any more.&lt;/p&gt;

&lt;p&gt;So remember one number for this whole article.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;wanted&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;running&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machines&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The restart line: what it protects, and what it cannot
&lt;/h2&gt;

&lt;p&gt;But the file has a restart line. Is that not enough? Test it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first failure: one container crashes.&lt;/strong&gt; The engine on node A sees it, and starts that container again in a few seconds. Running: three again. Good. (The Linux processes episode shows the same idea with systemd.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second failure: node A itself dies.&lt;/strong&gt; The engine was on node A, so the engine is dead too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;what happens&lt;/th&gt;
&lt;th&gt;running&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before&lt;/td&gt;
&lt;td&gt;3 copies on node A&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a crash&lt;/td&gt;
&lt;td&gt;one container crashes, the engine restarts it&lt;/td&gt;
&lt;td&gt;2, then 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03:00&lt;/td&gt;
&lt;td&gt;node A dies, and the engine dies with it&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03:40&lt;/td&gt;
&lt;td&gt;a person wakes up and fixes it&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is &lt;strong&gt;forty minutes with zero copies&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the restart line protects a container. Here is the second question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What protects you from a dead machine?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer: a loop that never exits
&lt;/h2&gt;

&lt;p&gt;An orchestrator answers that question with a loop.&lt;/p&gt;

&lt;p&gt;This code is a small model of what Kubernetes, Swarm and Nomad do inside. You do not copy it into your service. It only helps you reason about them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DESIRED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;          &lt;span class="c1"&gt;# copies of web we asked for
&lt;/span&gt;&lt;span class="n"&gt;ROOM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;             &lt;span class="c1"&gt;# copies one node can hold
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;alive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# keep only the nodes that are alive
&lt;/span&gt;    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DESIRED&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                     &lt;span class="c1"&gt;# desired, minus the copies that run
&lt;/span&gt;    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;ROOM&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c1"&gt;# nodes that still have room
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;                                               &lt;span class="c1"&gt;# no room: those copies stay pending
&lt;/span&gt;        &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;free&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;              &lt;span class="c1"&gt;# the node with the fewest copies
&lt;/span&gt;        &lt;span class="n"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;                                      &lt;span class="c1"&gt;# start one copy there
&lt;/span&gt;        &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;alive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;read_nodes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                               &lt;span class="c1"&gt;# again every five seconds, forever
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DESIRED&lt;/code&gt; is three:&lt;/strong&gt; the copies we asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ROOM&lt;/code&gt; is two:&lt;/strong&gt; one node can hold two copies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;reconcile&lt;/code&gt; function first keeps only the nodes that are alive.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then it counts what is missing:&lt;/strong&gt; desired, minus the copies that run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;While something is missing, it lists the nodes that still have room.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If no node has room, it stops,&lt;/strong&gt; and those copies stay &lt;strong&gt;pending&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Otherwise it picks the node with the fewest copies,&lt;/strong&gt; and starts one copy there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;At the bottom, the function runs again every five seconds, forever.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The same picture, with one change
&lt;/h2&gt;

&lt;p&gt;In the middle there is now a &lt;strong&gt;control loop&lt;/strong&gt;, and it never exits.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The file&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one YAML file, one number&lt;/td&gt;
&lt;td&gt;holds the number you want: 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The control loop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the orchestrator, a service that never exits&lt;/td&gt;
&lt;td&gt;reads the status of every node and starts what is missing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nodes A, B, C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3 servers, 1 container engine each&lt;/td&gt;
&lt;td&gt;run one copy each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You send the file once, and the loop keeps the number. The arrows between the loop and the nodes now go &lt;strong&gt;both ways&lt;/strong&gt;: status comes up, start commands go down.&lt;/p&gt;

&lt;p&gt;Now node A dies, the same machine as before.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;what the loop does&lt;/th&gt;
&lt;th&gt;node A&lt;/th&gt;
&lt;th&gt;node B&lt;/th&gt;
&lt;th&gt;node C&lt;/th&gt;
&lt;th&gt;running&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;asks every node for its status; node A does not answer&lt;/td&gt;
&lt;td&gt;dead&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;counts: desired 3, running 2, &lt;strong&gt;missing 1&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;dead&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;node B and node C each hold one copy, so it picks node B&lt;/td&gt;
&lt;td&gt;dead&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Running: three again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No person woke up. And two copies answered users during the whole time.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What you write: a file, not the loop
&lt;/h2&gt;

&lt;p&gt;So what do you write? Not the loop. You write a file, and it is very close to the Compose file.&lt;/p&gt;

&lt;p&gt;This file is for Kubernetes, and it is called a &lt;strong&gt;Deployment&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.example.com/web:1.4&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;replicas: 3&lt;/code&gt;.&lt;/strong&gt; That is the same number as before.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The image is the same too.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;memory&lt;/code&gt; line is new.&lt;/strong&gt; It says that one copy needs two gigabytes. The orchestrator uses that line to decide which node has room.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You send this file once, and the control loop keeps it.&lt;/p&gt;

&lt;p&gt;Now the cost in time. Kubernetes does not replace the copies at once. &lt;strong&gt;By default, it waits about five minutes for a silent node to come back.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The catch: the loop cannot create machines
&lt;/h2&gt;

&lt;p&gt;And here is the catch.&lt;/p&gt;

&lt;p&gt;The loop can start containers. It cannot create machines. So the room must exist &lt;strong&gt;before&lt;/strong&gt; the failure.&lt;/p&gt;

&lt;p&gt;The check is one line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;survives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;desired&lt;/span&gt;

&lt;span class="nf"&gt;survives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# (3 - 1) * 2 = 4    4 &amp;gt;= 3    True
&lt;/span&gt;&lt;span class="nf"&gt;survives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# (3 - 2) * 2 = 2    2 &amp;lt;  3    False
# False: 2 copies run, 1 copy stays pending
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the nodes, subtract the failures, and multiply by the room on each node. The result must be at least the desired number.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;nodes&lt;/th&gt;
&lt;th&gt;failures&lt;/th&gt;
&lt;th&gt;room that is left&lt;/th&gt;
&lt;th&gt;desired&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2 nodes × 2 = &lt;strong&gt;4&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4 is at least 3: &lt;strong&gt;we survive&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1 node × 2 = &lt;strong&gt;2&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2 is less than 3: &lt;strong&gt;2 copies run, 1 stays pending&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;You pay for spare room, or the loop has nowhere to put a copy.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The bill, because it is real
&lt;/h2&gt;

&lt;p&gt;So which one do you choose?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Compose&lt;/th&gt;
&lt;th&gt;an orchestrator&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;what it is&lt;/td&gt;
&lt;td&gt;a command that runs once&lt;/td&gt;
&lt;td&gt;a loop that never exits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machines for the work&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3 in our example, with spare room&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machines for the control loop&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;usually 3 of its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a container crashes&lt;/td&gt;
&lt;td&gt;the engine restarts it&lt;/td&gt;
&lt;td&gt;the loop repairs it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a machine dies&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 copies until a person comes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;no outage&lt;/strong&gt;: 2 copies keep answering, and the third is started again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what you learn and run&lt;/td&gt;
&lt;td&gt;one file, one command&lt;/td&gt;
&lt;td&gt;more machines and more to learn&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why three machines for the control loop? Because the control loop must survive a dead machine too. Three can lose one, because two of three still agree.&lt;/p&gt;

&lt;p&gt;And what you pay for it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what you gained&lt;/th&gt;
&lt;th&gt;what you pay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A dead machine causes no outage.&lt;/td&gt;
&lt;td&gt;Three nodes with spare room, not one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No person has to wake up at 03:00.&lt;/td&gt;
&lt;td&gt;A control plane of three machines, and more to learn.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The number in the file is kept, not only started.&lt;/td&gt;
&lt;td&gt;A default wait of about five minutes in Kubernetes before a silent node is replaced.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Compose is right&lt;/strong&gt; for your laptop, for tests, and for a small product that can accept a short outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose the orchestrator&lt;/strong&gt; when one machine is not enough, or when forty minutes of zero is not acceptable.&lt;/p&gt;




&lt;h2&gt;
  
  
  For your next interview
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between Docker Compose and an orchestrator?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Docker Compose is a command. It reads one file and starts your containers on one machine, one time. An orchestrator, such as Kubernetes, is a control loop over many machines. It compares what you want with what is running, again and again, and it repairs the difference.&lt;/p&gt;

&lt;p&gt;Three words to remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desired state&lt;/strong&gt;: the number of copies you asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile&lt;/strong&gt;: compare desired with running, and start what is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headroom&lt;/strong&gt;: the spare room on other nodes that a repair needs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The one-line verdict
&lt;/h2&gt;

&lt;p&gt;Compose is a command that reads your file once on one machine; an orchestrator is a loop that keeps the number, so &lt;strong&gt;stay on Compose while a short outage is acceptable, and move to an orchestrator when one machine is not enough or not safe enough&lt;/strong&gt;, and pay for the spare room first.&lt;/p&gt;

&lt;p&gt;One thing to go and look at tonight: open your Compose file and count the machines it runs on. If the answer is one, decide how many minutes of zero copies your product can accept.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the Compose file and the command&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker, &lt;em&gt;Compose Deploy Specification&lt;/em&gt; (Docker documentation) — &lt;code&gt;replicas&lt;/code&gt; specifies the number of containers that should be running at any given time: &lt;a href="https://docs.docker.com/reference/compose-file/deploy/" rel="noopener noreferrer"&gt;docs.docker.com — Compose Deploy Specification&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, &lt;em&gt;docker compose up&lt;/em&gt; (Docker documentation) — the command builds, creates and starts the containers for a service, and with &lt;code&gt;--detach&lt;/code&gt; it leaves them running in the background: &lt;a href="https://docs.docker.com/reference/cli/docker/compose/up/" rel="noopener noreferrer"&gt;docs.docker.com — docker compose up&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the restart line&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker, &lt;em&gt;Start containers automatically&lt;/em&gt; (Docker documentation) — restart policies control whether a container starts again automatically when it exits, and &lt;code&gt;unless-stopped&lt;/code&gt; is one of them: &lt;a href="https://docs.docker.com/engine/containers/start-containers-automatically/" rel="noopener noreferrer"&gt;docs.docker.com — Start containers automatically&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the control loop and the desired state&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Kubernetes Authors, &lt;em&gt;Controllers&lt;/em&gt; (Kubernetes documentation) — a control loop is a loop that never ends and regulates the state of a system; the page uses the thermostat as its example, and each controller moves the current state closer to the desired state: &lt;a href="https://kubernetes.io/docs/concepts/architecture/controller/" rel="noopener noreferrer"&gt;kubernetes.io — Controllers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Burns, Grant, Oppenheimer, Brewer and Wilkes, &lt;em&gt;Borg, Omega, and Kubernetes&lt;/em&gt; (ACM Queue, 2016) — the reconciliation controller loop, which compares a desired state with the observed state, is shared by all three systems and makes them robust to failures: &lt;a href="https://research.google/pubs/borg-omega-and-kubernetes/" rel="noopener noreferrer"&gt;research.google — Borg, Omega, and Kubernetes&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the Deployment file, the memory line and the five-minute wait&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Kubernetes Authors, &lt;em&gt;Resource Management for Pods and Containers&lt;/em&gt; (Kubernetes documentation) — the scheduler uses the resource request of a container to decide which node to place it on, and a Pod that fits on no node stays unscheduled until a place is found: &lt;a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" rel="noopener noreferrer"&gt;kubernetes.io — Resource Management for Pods and Containers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The Kubernetes Authors, &lt;em&gt;Taints and Tolerations&lt;/em&gt; (Kubernetes documentation) — Kubernetes adds a toleration of 300 seconds for a node that is not ready or unreachable, so Pods stay bound to that node for 5 minutes after the problem is detected: &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/" rel="noopener noreferrer"&gt;kubernetes.io — Taints and Tolerations&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the three machines of the control loop&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker, &lt;em&gt;Raft consensus in swarm mode&lt;/em&gt; (Docker documentation) — Raft tolerates up to (N-1)/2 failures and needs a majority of (N/2)+1 members to agree, so three managers can lose one: &lt;a href="https://docs.docker.com/engine/swarm/raft/" rel="noopener noreferrer"&gt;docs.docker.com — Raft consensus in swarm mode&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/wTKojMOXBk8" rel="noopener noreferrer"&gt;Docker Compose vs an orchestrator — Explained in Detail on YouTube&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Container Images, Layers &amp; Registries — An Image Is Not One Big File: One Changed Line, 150 MB Moved, and the Dockerfile Order That Makes It 2 MB</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 10 Oct 2026 09:52:26 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/container-images-layers-registries-an-image-is-not-one-big-file-one-changed-line-150-mb-115n</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/container-images-layers-registries-an-image-is-not-one-big-file-one-changed-line-150-mb-115n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/fvxu_Mt1tKk" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/container-images-layers-registries-an-image-is-not-one-big-file-one-changed-line-150-mb-moved-and-the-dockerfile-order-that-makes-it-2-mb?id=233" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Container images, layers, and registries. Many people think that an image is one big file. &lt;strong&gt;It is not.&lt;/strong&gt; It is a stack of smaller files, and each one is stored and sent on its own.&lt;/p&gt;

&lt;p&gt;This article follows one image of 200 megabytes and one changed line of code. It reads two Dockerfiles, two small Python functions and one deploy file, line by line. At the end you know why the order of a Dockerfile decides how much is built and moved, and why a tag does not tell you what really runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  You changed one line. Why did 150 MB move?
&lt;/h2&gt;

&lt;p&gt;You run one service as &lt;strong&gt;three pods&lt;/strong&gt;. A pod is one running copy of your service. Every deploy builds a new container image, and every server downloads it.&lt;/p&gt;

&lt;p&gt;The image is two hundred megabytes. Today you change one line of code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;what happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;the build installs every dependency again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;the build uploads &lt;strong&gt;150 MB&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;every server downloads &lt;strong&gt;150 MB&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;your change was one line, in a file of a few kilobytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So here is the question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You changed one line. Why did one hundred and fifty megabytes move?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers (and what it does not)
&lt;/h2&gt;

&lt;p&gt;The subject is DevOps, and one part of it: how a container image is built, stored and delivered.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;image&lt;/strong&gt; is the packed file system that a container starts from.&lt;/p&gt;

&lt;p&gt;This article covers three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;layers&lt;/strong&gt;, which are the pieces of an image;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the build cache&lt;/strong&gt;, which decides what is built again;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the registry&lt;/strong&gt;, which is the server that stores images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Left alone on purpose: &lt;strong&gt;the running process and its signals&lt;/strong&gt;. That has its own episode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where does this live? It is built into your tools.&lt;/strong&gt; You will not write this machinery yourself. It is built into Docker, Podman, containerd and Kubernetes. What you write is the Dockerfile, and the image name in your deploy file. Those two decide your build time, and what really runs in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  A reprinted textbook and a thin clear sheet
&lt;/h2&gt;

&lt;p&gt;Here are two ways to publish a correction to a thick textbook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first way: print the whole book again for every correction.&lt;/strong&gt; Three corrections give you three thick books, and every reader must carry all of that paper home.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second way: keep one book.&lt;/strong&gt; Write each correction on a thin clear sheet, and lay the sheet over the page. To publish a correction, you send only the sheet. Every reader already has the book.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;reprint the book&lt;/th&gt;
&lt;th&gt;lay a sheet over the page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;three corrections&lt;/td&gt;
&lt;td&gt;three thick books&lt;/td&gt;
&lt;td&gt;one book, three thin sheets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what you send&lt;/td&gt;
&lt;td&gt;the whole book, every time&lt;/td&gt;
&lt;td&gt;only the new sheet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what the reader already has&lt;/td&gt;
&lt;td&gt;nothing that helps&lt;/td&gt;
&lt;td&gt;the book&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;in an image&lt;/td&gt;
&lt;td&gt;one big file, copied whole&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;a base layer, plus thin layers&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A container image works the second way.&lt;/strong&gt; The book is the base layer. Each sheet is one layer on top of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The whole system, drawn once
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The build machine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 CI runner, with 1 layer cache&lt;/td&gt;
&lt;td&gt;reads your Dockerfile and builds the image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The registry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 HTTP server&lt;/td&gt;
&lt;td&gt;stores every layer under its digest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The nodes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 servers that run your pods&lt;/td&gt;
&lt;td&gt;pull layers and start containers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;strong&gt;digest&lt;/strong&gt; is a hash of the bytes, so the same bytes always get the same name. A &lt;strong&gt;node&lt;/strong&gt; is one server that runs your pods.&lt;/p&gt;

&lt;p&gt;Two arrows join the parts. The build machine &lt;strong&gt;pushes&lt;/strong&gt; layers to the registry. Each node &lt;strong&gt;pulls&lt;/strong&gt; layers from the registry. Layer bytes move in one direction only: a node never sends a layer back.&lt;/p&gt;

&lt;p&gt;Now one image for the whole article. Its name is &lt;code&gt;shop&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;layer&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the base layer&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the dependencies&lt;/td&gt;
&lt;td&gt;148 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;your code&lt;/td&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;the image&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Dockerfile that most people write first
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Dockerfile&lt;/strong&gt; is the list of steps that builds an image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim                      #  50 MB&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /app                                #   2 MB&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; /app/requirements.txt   &lt;span class="c"&gt;# 148 MB&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "/app/main.py"]             # no layer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line one is &lt;code&gt;FROM&lt;/code&gt;.&lt;/strong&gt; It starts from a base image that already has Python. That is the 50 MB layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line two is &lt;code&gt;COPY&lt;/code&gt;.&lt;/strong&gt; It copies your whole project into the image. That is the 2 MB layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line three is &lt;code&gt;RUN&lt;/code&gt;.&lt;/strong&gt; It installs the dependencies. That is the 148 MB layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Line four is &lt;code&gt;CMD&lt;/code&gt;.&lt;/strong&gt; It only records which command to start, so it adds no files.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;layer&lt;/th&gt;
&lt;th&gt;layer size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;base image&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY . /app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;your code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN pip install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dependencies&lt;/td&gt;
&lt;td&gt;148 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three steps made three layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remember the order: the code layer comes before the dependency layer.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The build cache: one key for each step
&lt;/h2&gt;

&lt;p&gt;Now a small model of what the build tool does inside. It is not code for your service. It helps you reason about the tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rebuilt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;rebuilt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rebuilt&lt;/span&gt;

&lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FROM base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COPY . .&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RUN pip install&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every step gets a cache key.&lt;/strong&gt; The key is a hash of three things: the key of the step before it, the text of the step, and the files that the step copies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;build&lt;/code&gt; function walks the steps in order.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If the key is already in the cache, the tool reuses the old layer.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If the key is new, the tool runs the step again.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now change one line of code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;its key&lt;/th&gt;
&lt;th&gt;what the tool does&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same&lt;/td&gt;
&lt;td&gt;reuses the layer&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY . /app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;new&lt;/strong&gt;, because a copied file changed&lt;/td&gt;
&lt;td&gt;runs the step again&lt;/td&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN pip install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;new&lt;/strong&gt;, because its key includes the key before it&lt;/td&gt;
&lt;td&gt;runs the step again&lt;/td&gt;
&lt;td&gt;148 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;COPY&lt;/code&gt; step gets a new key. The next step includes that key, so its key changes too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One changed step rebuilds every step after it: 2 + 148 = 150 MB.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: change the order
&lt;/h2&gt;

&lt;p&gt;Put the steps that change rarely first, and the steps that change often last.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt /app/&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; /app/requirements.txt
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /app&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "/app/main.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line two now copies only one file:&lt;/strong&gt; the list of dependencies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line three installs them.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line four copies your code, last.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now change one line of code again.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;cache&lt;/th&gt;
&lt;th&gt;layer size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FROM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reused&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY requirements.txt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reused&lt;/td&gt;
&lt;td&gt;0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN pip install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reused&lt;/td&gt;
&lt;td&gt;148 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY . /app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;REBUILT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Line two copies the same file as before, so its key is the same. Line three has the same key too. The tool reuses both layers. Only line four runs again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One line of code now rebuilds 2 MB, not 150.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The registry asks one question for every layer
&lt;/h2&gt;

&lt;p&gt;The registry uses the same idea. Here is the model of a push.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# do you have this digest?
&lt;/span&gt;            &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;     &lt;span class="c1"&gt;# no: upload this layer
&lt;/span&gt;            &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;

&lt;span class="n"&gt;pull&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;push&lt;/span&gt;    &lt;span class="c1"&gt;# the same loop, against the disk of the node
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For every layer, the tool first asks the registry one question:&lt;/strong&gt; do you already have this digest?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If the answer is yes, the tool sends nothing.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If the answer is no, the tool uploads that layer.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pull works the same way,&lt;/strong&gt; against the disk of the node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now run both Dockerfiles after the one-line change.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;order&lt;/th&gt;
&lt;th&gt;rebuilt&lt;/th&gt;
&lt;th&gt;pushed&lt;/th&gt;
&lt;th&gt;pulled per node&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;code first&lt;/td&gt;
&lt;td&gt;150 MB&lt;/td&gt;
&lt;td&gt;150 MB&lt;/td&gt;
&lt;td&gt;150 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;code last&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Layers that did not change never move again.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The trap: three pods, one tag
&lt;/h2&gt;

&lt;p&gt;Here is the same picture, one day later.&lt;/p&gt;

&lt;p&gt;Your team pushes every build under the same tag: &lt;code&gt;shop:prod&lt;/code&gt;. A &lt;strong&gt;tag&lt;/strong&gt; is a readable name that points at one image.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;day&lt;/th&gt;
&lt;th&gt;what happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monday&lt;/td&gt;
&lt;td&gt;nodes one and two pull &lt;code&gt;shop:prod&lt;/code&gt;. They keep Monday's image on disk.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuesday&lt;/td&gt;
&lt;td&gt;you push a new build under the same tag. The registry moves the tag to the new image.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuesday&lt;/td&gt;
&lt;td&gt;the cluster adds node three. It has an empty disk, so it pulls the tag, and it gets Tuesday's image.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuesday&lt;/td&gt;
&lt;td&gt;nodes one and two already have that tag on disk, so they pull nothing.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the second question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three pods run the same tag. Are they running the same code?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer: a tag can move, a digest cannot
&lt;/h2&gt;

&lt;p&gt;No. Two pods run Monday's code, and one pod runs Tuesday's code.&lt;/p&gt;

&lt;p&gt;The cause is that a tag can move. A digest cannot. The digest of an image is a hash of its list of layers. If one byte changes, the digest changes.&lt;/p&gt;

&lt;p&gt;So this is the guard. This is the deploy file that Kubernetes reads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# by tag: a tag can move&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop:prod&lt;/span&gt;

&lt;span class="c1"&gt;# by digest: a hash of its list of layers, so it cannot move&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shop@sha256:9f2c41d07b6e58a3c1f04e7d92ab35c68e10f7a4d3b925c06e8f1a7b4d2c93e5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The first line names the image by tag.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The second line names it by digest,&lt;/strong&gt; after an at sign.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now every node must run exactly those bytes. A node that holds a different image pulls the right one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;image named&lt;/th&gt;
&lt;th&gt;node one&lt;/th&gt;
&lt;th&gt;node two&lt;/th&gt;
&lt;th&gt;node three&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;by tag&lt;/td&gt;
&lt;td&gt;monday&lt;/td&gt;
&lt;td&gt;monday&lt;/td&gt;
&lt;td&gt;tuesday&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;by digest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;tuesday&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;tuesday&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;tuesday&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Give each build its own tag, and deploy by digest.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The catch: a layer only adds
&lt;/h2&gt;

&lt;p&gt;There is one catch that surprises people.&lt;/p&gt;

&lt;p&gt;A layer only adds changes. It never removes anything from the layers before it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;what it does&lt;/th&gt;
&lt;th&gt;what the image holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;copies a password file into the image&lt;/td&gt;
&lt;td&gt;a layer &lt;strong&gt;with&lt;/strong&gt; the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;deletes that file&lt;/td&gt;
&lt;td&gt;a new layer that says: this file is gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;the final file system&lt;/td&gt;
&lt;td&gt;does not show the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;the earlier layer&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;still holds the file&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Anyone who pulls the image can read that earlier layer. The image also did not get smaller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So never copy a secret into an image.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The bill, because it is real
&lt;/h2&gt;

&lt;p&gt;One deploy is one push and three pulls, one pull for each node. So four moves.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;code first&lt;/th&gt;
&lt;th&gt;code last&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one move&lt;/td&gt;
&lt;td&gt;150 MB&lt;/td&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one deploy: 4 moves&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;600 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ten deploys a day&lt;/td&gt;
&lt;td&gt;6,000 MB&lt;/td&gt;
&lt;td&gt;80 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;6,000 divided by 80 is 75.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same service, the same code, the same image: 75 times less to build and move.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And what you pay for it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what you gained&lt;/th&gt;
&lt;th&gt;what you pay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A one-line change rebuilds and moves 2 MB, not 150.&lt;/td&gt;
&lt;td&gt;The order of the Dockerfile is now a rule that the team must keep.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every node runs exactly the same bytes.&lt;/td&gt;
&lt;td&gt;The deploy file names a long digest, so a tool must write it for each build.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layers that did not change never move again.&lt;/td&gt;
&lt;td&gt;A layer never forgets: one copied secret stays in the image.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  For your next interview
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a container image, and why does the order of a Dockerfile matter?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A container image is a stack of read-only layers, plus a small file that lists them. Tools like Docker and containerd store, push and pull each layer on its own.&lt;/p&gt;

&lt;p&gt;Four words to remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A layer&lt;/strong&gt;: the files that one build step added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A digest&lt;/strong&gt;: a hash of the bytes, which can never point at different bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tag&lt;/strong&gt;: a readable name, which can be moved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A registry&lt;/strong&gt;: the server that stores layers by digest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The order matters because one changed step rebuilds every step after it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one-line verdict
&lt;/h2&gt;

&lt;p&gt;An image is a stack of layers that are cached, pushed and pulled one by one, so &lt;strong&gt;put the steps that change rarely first and your code last&lt;/strong&gt;, and name the image &lt;strong&gt;by digest&lt;/strong&gt;, because a tag can move.&lt;/p&gt;

&lt;p&gt;One thing to go and look at tonight: open the Dockerfile of your main service. Find the line that copies your code, and the line that installs the dependencies. Check which line comes first.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On layers and digests&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open Container Initiative, &lt;em&gt;OCI Image Format Specification: Image Manifest&lt;/em&gt; — an image is a manifest that lists its layers in order, and each layer is named by its digest: &lt;a href="https://github.com/opencontainers/image-spec/blob/main/manifest.md" rel="noopener noreferrer"&gt;github.com — image-spec, manifest.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Open Container Initiative, &lt;em&gt;OCI Image Format Specification: Content Descriptors&lt;/em&gt; — a digest identifies content by a collision-resistant hash of its bytes, such as SHA-256: &lt;a href="https://github.com/opencontainers/image-spec/blob/main/descriptor.md" rel="noopener noreferrer"&gt;github.com — image-spec, descriptor.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the build cache and the order of the steps&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker, &lt;em&gt;Docker build cache&lt;/em&gt; (Docker documentation) — once a layer changes, all the layers after it must be rebuilt as well: &lt;a href="https://docs.docker.com/build/cache/" rel="noopener noreferrer"&gt;docs.docker.com — Build cache&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, &lt;em&gt;Optimize cache usage in builds&lt;/em&gt; (Docker documentation) — steps that change often belong near the end of the Dockerfile, with the dependency file copied and installed before the rest of the code: &lt;a href="https://docs.docker.com/build/cache/optimize/" rel="noopener noreferrer"&gt;docs.docker.com — Optimize cache usage&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, &lt;em&gt;Build cache invalidation&lt;/em&gt; (Docker documentation) — the real rules behind the cache key; for &lt;code&gt;COPY&lt;/code&gt; and &lt;code&gt;ADD&lt;/code&gt; the builder computes a checksum from the files involved: &lt;a href="https://docs.docker.com/build/cache/invalidation/" rel="noopener noreferrer"&gt;docs.docker.com — Cache invalidation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the registry, push and pull&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open Container Initiative, &lt;em&gt;OCI Distribution Specification&lt;/em&gt; — a client checks whether the registry already has a blob with a &lt;code&gt;HEAD&lt;/code&gt; request on its digest, and a tag is a human-readable pointer to a manifest: &lt;a href="https://github.com/opencontainers/distribution-spec/blob/main/spec.md" rel="noopener noreferrer"&gt;github.com — distribution-spec, spec.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On tags, digests and the pull policy&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Kubernetes Authors, &lt;em&gt;Images&lt;/em&gt; (Kubernetes documentation) — with the pull policy &lt;code&gt;IfNotPresent&lt;/code&gt; the kubelet skips the pull when the image is already on the node; if a tag changes you can end up with a mix of Pods on old and new code, and a digest names one specific version of the image: &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;kubernetes.io — Images&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the catch: a layer only adds&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open Container Initiative, &lt;em&gt;OCI Image Format Specification: Image Layer Filesystem Changeset&lt;/em&gt; — a removal is stored as a whiteout file in the new layer, so the earlier layer is not changed: &lt;a href="https://github.com/opencontainers/image-spec/blob/main/layer.md" rel="noopener noreferrer"&gt;github.com — image-spec, layer.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, &lt;em&gt;Build secrets&lt;/em&gt; (Docker documentation) — build arguments and environment variables are the wrong place for a secret, because they persist in the final image; a secret mount gives the build access without storing it: &lt;a href="https://docs.docker.com/build/building/secrets/" rel="noopener noreferrer"&gt;docs.docker.com — Build secrets&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/fvxu_Mt1tKk" rel="noopener noreferrer"&gt;Container images, layers &amp;amp; registries — Explained in Detail on YouTube&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>async/await Explained: How One Thread Serves a Hundred Waiting Tasks</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:23:49 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/asyncawait-explained-how-one-thread-serves-a-hundred-waiting-tasks-3f0f</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/asyncawait-explained-how-one-thread-serves-a-hundred-waiting-tasks-3f0f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/ZrMKhosYbi4" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/asyncawait-explained-how-one-thread-serves-a-hundred-waiting-tasks?id=208" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every async tutorial starts at the syntax. Start one level below it instead.&lt;/p&gt;

&lt;p&gt;A hundred requests are in flight on your server. Every one of them is waiting on the database. Now take away their hundred threads, and a single question is left standing: &lt;strong&gt;where does each half-finished function keep its place?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question is all &lt;code&gt;async&lt;/code&gt; / &lt;code&gt;await&lt;/code&gt; really answers. Everything else — the keywords, the coloured-function arguments, the library churn — is downstream of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  A thread is a stack, and a stack is a bookmark
&lt;/h2&gt;

&lt;p&gt;Ask what a thread actually buys you and the answer is disappointingly concrete: &lt;strong&gt;a stack&lt;/strong&gt;. That stack exists to hold two things — your &lt;strong&gt;local variables&lt;/strong&gt;, and &lt;strong&gt;the line you are on&lt;/strong&gt;. That is it. When the CPU switches away from your thread and comes back, the stack is how it knows where "back" was.&lt;/p&gt;

&lt;p&gt;Look at a perfectly ordinary handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;# a local
&lt;/span&gt;    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# ← 200 ms of waiting
&lt;/span&gt;    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="c1"&gt;# a local
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time it honestly and it spends maybe &lt;strong&gt;two milliseconds computing&lt;/strong&gt; and &lt;strong&gt;two hundred milliseconds waiting&lt;/strong&gt;. For that entire 200 ms the thread is doing nothing at all. It is not slow; it is &lt;em&gt;parked&lt;/em&gt;. And it is holding a full stack open the whole time, purely so that when &lt;code&gt;db.query&lt;/code&gt; returns, something remembers that &lt;code&gt;started&lt;/code&gt; was &lt;code&gt;1726...&lt;/code&gt; and that the next line to run is the one assigning &lt;code&gt;elapsed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the bill nobody itemises. On Linux a pthread reserves &lt;strong&gt;8 MB of address space&lt;/strong&gt; for its stack by default (&lt;code&gt;ulimit -s&lt;/code&gt;), committed lazily page by page; runtimes that choose their own are usually in the &lt;strong&gt;~1 MB&lt;/strong&gt; neighbourhood. Round it to a megabyte and the arithmetic is brutal:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100 parked requests → ~100 MB of memory held open to remember three variables and a line number.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You are not paying for work. You are paying for &lt;em&gt;bookkeeping&lt;/em&gt;, at the price of a data structure built for something far more general.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;await&lt;/code&gt; is a suspension point, not "go faster"
&lt;/h2&gt;

&lt;p&gt;Here is the misreading that survives most tutorials: &lt;code&gt;await&lt;/code&gt; does not make the database answer sooner. It does not add a thread. It does not parallelise anything.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;await&lt;/code&gt; marks a &lt;strong&gt;suspension point&lt;/strong&gt; — the line your function is allowed to stop on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ← stops HERE
&lt;/span&gt;    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When control reaches that &lt;code&gt;await&lt;/code&gt;, three things happen in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The function &lt;strong&gt;stops&lt;/strong&gt;, mid-body, with two lines still unexecuted.&lt;/li&gt;
&lt;li&gt;It registers interest: &lt;em&gt;wake me when the row is ready.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;hands control back&lt;/strong&gt; to whoever is running it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the one that pays. The thread is not parked on your function any more — it is free, immediately, to go run somebody else's.&lt;/p&gt;

&lt;p&gt;But step 1 leaves a debt. A function that stopped halfway still has a &lt;code&gt;started&lt;/code&gt; value and a resume point, and those have to live &lt;em&gt;somewhere&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the state goes: a frame on the heap
&lt;/h2&gt;

&lt;p&gt;This is the whole trick, and it is smaller than it sounds.&lt;/p&gt;

&lt;p&gt;Instead of keeping a megabyte-sized stack alive to hold three values, the runtime copies &lt;strong&gt;the locals plus the resume point&lt;/strong&gt; into a small object on the &lt;strong&gt;heap&lt;/strong&gt; — a coroutine frame. A few hundred bytes. In CPython you can hold one in your hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="n"&gt;coro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# nothing has run yet
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coro&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;         &lt;span class="c1"&gt;# &amp;lt;class 'coroutine'&amp;gt;
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cr_frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;f_locals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the locals live here, on the heap
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing executed when you called it. Calling an async function does not run it — it &lt;strong&gt;builds the bookmark&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A stack is a room you rent to hold your place. A coroutine frame is a slip of paper with your place written on it. Both remember the same two facts. One costs a megabyte of reserved address space; one costs the size of the variables you actually have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100 parked requests → ~30 KB of heap frames&lt;/strong&gt;, rather than ~100 MB of stacks. Same information. Three orders of magnitude apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  One thread, a ready queue of bookmarks
&lt;/h2&gt;

&lt;p&gt;Now the loop. One thread holds a &lt;strong&gt;ready queue&lt;/strong&gt; of those frames and does something almost boringly simple, forever:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the next ready frame off the queue.&lt;/li&gt;
&lt;li&gt;Run it &lt;strong&gt;up to its next &lt;code&gt;await&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Put it aside; take the next one.&lt;/li&gt;
&lt;li&gt;When an I/O completes, push that frame back onto the ready queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is where the operating system earns its keep. The loop is not spinning and checking each socket in turn — it blocks in one &lt;code&gt;epoll_wait&lt;/code&gt;-style call that says &lt;em&gt;wake me when any of these three hundred sockets is ready&lt;/em&gt;, and the kernel hands back exactly the ones that are.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# a hundred bookmarks, one thread
&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gather&lt;/code&gt; does not start a hundred threads. It creates a hundred frames, and every one of them runs on the same thread, up to its first &lt;code&gt;await&lt;/code&gt;, in turn.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resuming: the line &lt;em&gt;after&lt;/em&gt; the &lt;code&gt;await&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When your row finally arrives, the frame rejoins the ready queue, the loop reopens it, and execution continues &lt;strong&gt;on the line after the &lt;code&gt;await&lt;/code&gt;&lt;/strong&gt; — with &lt;code&gt;started&lt;/code&gt; exactly as you left it.&lt;/p&gt;

&lt;p&gt;It did not restart. It did not re-run &lt;code&gt;time.time()&lt;/code&gt;. It was &lt;strong&gt;parked&lt;/strong&gt;, with no thread held open to wait for it.&lt;/p&gt;

&lt;p&gt;That is the entire mechanism. A suspension point, a bookmark on the heap, and a loop that reopens bookmarks in the order they become ready.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&amp;nbsp;&lt;/th&gt;
      &lt;th&gt;Thread per request&lt;/th&gt;
      &lt;th&gt;async / await&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;What holds your place&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A stack&lt;/td&gt;
      &lt;td&gt;A frame on the heap&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cost per waiting request&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;~1 MB (8 MB reserved on Linux)&lt;/td&gt;
      &lt;td&gt;Hundreds of bytes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Who decides to switch&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;The OS scheduler, at any instruction&lt;/td&gt;
      &lt;td&gt;Your code, only at an &lt;code&gt;await&lt;/code&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cost of a switch&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Kernel context switch&lt;/td&gt;
      &lt;td&gt;A function return, in user space&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Realistic concurrent waiters&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Thousands, then memory bites&lt;/td&gt;
      &lt;td&gt;Tens of thousands on one thread&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Helps CPU-bound work&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Yes, across cores&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — one thread, one core&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Fails when&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;You need many more waiters than RAM allows&lt;/td&gt;
      &lt;td&gt;One task refuses to yield&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The three catches, which are the whole interview
&lt;/h2&gt;

&lt;p&gt;Everything above is the part tutorials cover. These three are the part they skip — and they are exactly what a good interviewer probes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You bought concurrency on &lt;em&gt;waiting&lt;/em&gt;, never on &lt;em&gt;computing&lt;/em&gt;.&lt;/strong&gt; There is still one thread. If two tasks both need to hash a password, they do it one after the other. &lt;code&gt;async&lt;/code&gt; moved the &lt;em&gt;waiting&lt;/em&gt; out of the way; it did not add a core. CPU-bound work needs processes, threads, or a native extension that releases the GIL — not &lt;code&gt;await&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. One blocking call freezes everyone.&lt;/strong&gt; This is the failure mode that actually reaches production. A single synchronous call inside an async function — &lt;code&gt;requests.get&lt;/code&gt;, &lt;code&gt;time.sleep&lt;/code&gt;, a driver that is secretly synchronous, a 40 ms JSON parse — does not just stall &lt;em&gt;that&lt;/em&gt; task. It holds the only thread, so &lt;strong&gt;every other task on the loop stops too&lt;/strong&gt;, including ones whose data arrived ten milliseconds ago.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# fine: yields
&lt;/span&gt;    &lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 🔴 blocks the ENTIRE loop
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to push it off the loop rather than pretend it is async:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Nothing is pre-empted.&lt;/strong&gt; The OS can interrupt a thread anywhere. The event loop cannot. Scheduling here is &lt;strong&gt;cooperative&lt;/strong&gt;: a task keeps the thread until it chooses to yield, and it only ever chooses at an &lt;code&gt;await&lt;/code&gt;. A task that never awaits never lets anyone else run — a &lt;code&gt;while True:&lt;/code&gt; with no suspension point inside it is not a busy task, it is a stopped server.&lt;/p&gt;

&lt;p&gt;The mental test for any async code you write is one question: &lt;em&gt;between here and the next &lt;code&gt;await&lt;/code&gt;, how long is this thread mine?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The same mechanism, one floor up: serving LLMs
&lt;/h2&gt;

&lt;p&gt;This is not a backend-trivia pattern. It is the reason a single modest Python process can sit in front of a GPU cluster and hold thousands of open chat connections — and it is also the reason people misdiagnose their inference latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An LLM request is almost entirely waiting.&lt;/strong&gt; A completion that streams for eight seconds involves a few milliseconds of your code and eight seconds of the model producing tokens somewhere else. Thread-per-request would mean one megabyte-class stack per open conversation, parked, doing nothing. The gateway in front of a model is the purest possible async workload: thousands of bookmarks, one loop, near-zero CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming makes the suspension point visible.&lt;/strong&gt; When you iterate a token stream, every &lt;code&gt;async for&lt;/code&gt; is an &lt;code&gt;await&lt;/code&gt; — the loop hands the thread away between tokens, so one process can fan hundreds of concurrent streams out to hundreds of clients:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;            &lt;span class="c1"&gt;# suspends between tokens; loop stays free
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;But catch #1 sets a hard ceiling on what async can fix.&lt;/strong&gt; Async gives you concurrency on waiting, and the two numbers everyone tunes — &lt;strong&gt;TTFT&lt;/strong&gt; (time to first token) and &lt;strong&gt;TPOT&lt;/strong&gt; (time per output token) — are &lt;em&gt;not&lt;/em&gt; waiting. They are GPU work. No amount of &lt;code&gt;await&lt;/code&gt; shortens them. What actually raises throughput is &lt;strong&gt;continuous batching&lt;/strong&gt;: the server merges newly arrived requests into the running batch at every decoding iteration, rather than making them queue behind a finished batch. Note the symmetry — that scheduler is doing the same thing the event loop does, at the level of a token instead of a syscall: keep a queue of half-finished work, advance each one step, never hold an expensive resource open for something that is merely waiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And catch #2 is where real inference gateways bleed.&lt;/strong&gt; Tokenising a 100 KB prompt, a synchronous embedding call, a hefty &lt;code&gt;json.loads&lt;/code&gt; of a tool-call payload — each is pure CPU on the loop thread, and each stalls &lt;em&gt;every&lt;/em&gt; in-flight stream, not just its own. The symptom is maddening: the model is fast, the GPU is idle, and p99 latency is terrible. The cause is a blocking call inside an async handler.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;You never needed a hundred threads. You needed a hundred &lt;strong&gt;bookmarks&lt;/strong&gt; and one thread to turn the pages.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A thread is a stack, and a stack's whole job is to remember your locals and your line.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;await&lt;/code&gt; is where the runtime takes that memory and writes it on a slip of paper instead.&lt;/li&gt;
&lt;li&gt;One loop runs the slips, each up to its next suspension point, and resumes each exactly where it stopped.&lt;/li&gt;
&lt;li&gt;It buys you &lt;strong&gt;waiting&lt;/strong&gt;, not computing — and it holds only as long as every task keeps yielding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for it when your service is I/O-bound: an API gateway, a scraper, a websocket fan-out, a model proxy. Reach for processes when the work is genuinely CPU-bound. And whichever you choose, the diagnostic question stays the same: &lt;em&gt;who is holding this thread, and when do they give it back?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On what a thread actually costs: the stack&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;M. Kerrisk, &lt;em&gt;The Linux Programming Interface&lt;/em&gt; (No Starch Press, 2010) — chapters 29–33 on POSIX threads: what a thread owns privately (its stack) versus what it shares, and why the per-thread stack is the dominant per-thread cost.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;pthread_create(3)&lt;/em&gt; — Linux man-pages — states that a thread's stack size defaults to the &lt;code&gt;RLIMIT_STACK&lt;/code&gt; soft limit (commonly 8 MB on Linux) and that the stack is allocated per thread: &lt;a href="https://man7.org/linux/man-pages/man3/pthread_create.3.html" rel="noopener noreferrer"&gt;man7.org — pthread_create(3)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A. Silberschatz, P. Galvin, G. Gagne, &lt;em&gt;Operating System Concepts&lt;/em&gt; (Wiley, 10th ed., 2018) — chapter 4 on threads and chapter 5 on CPU scheduling: the context switch, and the pre-emptive model that the event loop deliberately does not use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On &lt;code&gt;await&lt;/code&gt; as a suspension point, and where the state lives&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Y. Selivanov, &lt;em&gt;PEP 492 — Coroutines with async and await syntax&lt;/em&gt; (Python, 2015) — the language-level definition: &lt;code&gt;await&lt;/code&gt; suspends execution of the coroutine and yields control, and calling a coroutine function returns a coroutine object rather than running it: &lt;a href="https://peps.python.org/pep-0492/" rel="noopener noreferrer"&gt;peps.python.org — PEP 492&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;G. van Rossum, &lt;em&gt;PEP 3156 — Asynchronous IO Support Rebooted: the "asyncio" Module&lt;/em&gt; (Python, 2012) — the design of the event loop itself: the ready queue, callbacks on I/O completion, and the single-threaded execution model: &lt;a href="https://peps.python.org/pep-3156/" rel="noopener noreferrer"&gt;peps.python.org — PEP 3156&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the loop and non-blocking I/O&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;M. Kerrisk, &lt;em&gt;The Linux Programming Interface&lt;/em&gt; (No Starch Press, 2010) — chapter 63 on alternative I/O models: &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;poll&lt;/code&gt; and &lt;code&gt;epoll&lt;/code&gt;, the syscall the loop actually blocks in while every task is suspended.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;The Node.js Event Loop&lt;/em&gt; — Node.js docs — the same single-threaded loop described phase by phase, for the runtime where this model is the default rather than a choice: &lt;a href="https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick" rel="noopener noreferrer"&gt;nodejs.org — event loop&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the three catches: blocking, cooperation, and what concurrency is not&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Developing with asyncio&lt;/em&gt; — Python docs — the explicit warning that blocking CPU-bound or I/O-bound calls must never be called directly from a coroutine because they block the whole event loop, plus &lt;code&gt;to_thread&lt;/code&gt; / &lt;code&gt;run_in_executor&lt;/code&gt; as the escape hatch: &lt;a href="https://docs.python.org/3/library/asyncio-dev.html" rel="noopener noreferrer"&gt;docs.python.org — developing with asyncio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Don't Block the Event Loop&lt;/em&gt; — Node.js docs — the same hazard stated as an operational rule, with the latency consequences for every other connection: &lt;a href="https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop" rel="noopener noreferrer"&gt;nodejs.org — don't block the event loop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;R. Pike, &lt;em&gt;Concurrency Is Not Parallelism&lt;/em&gt; (Heroku Waza talk, 2012) — the distinction catch #1 rests on: dealing with many things at once is a structural property, and it is not the same as doing many things at once: &lt;a href="https://go.dev/blog/waza-talk" rel="noopener noreferrer"&gt;go.dev — talk&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the LLM-serving section: why async cannot shorten TTFT&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;G. Yu et al., &lt;em&gt;Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em&gt; (OSDI, 2022) — introduces iteration-level scheduling, where the server revisits its batch at every decoding step instead of waiting for the slowest request to finish: &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu" rel="noopener noreferrer"&gt;usenix.org — Orca&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;W. Kwon et al., &lt;em&gt;Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em&gt; (SOSP, 2023) — the vLLM paper: continuous batching plus paged KV-cache memory, and the throughput ceiling that request-level concurrency alone cannot lift: &lt;a href="https://arxiv.org/abs/2309.06180" rel="noopener noreferrer"&gt;arxiv.org — 2309.06180&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/ZrMKhosYbi4" rel="noopener noreferrer"&gt;async/await: how ONE thread serves a hundred waiting tasks&lt;/a&gt;&lt;/p&gt;

</description>
      <category>async</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>python</category>
    </item>
    <item>
      <title>Design a notification system — a full mock system design interview (Ep 4)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:56:28 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/design-a-notification-system-a-full-mock-system-design-interview-ep-4-1g75</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/design-a-notification-system-a-full-mock-system-design-interview-ep-4-1g75</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/UTUmHPrJVFo" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/design-a-notification-system-a-full-mock-system-design-interview-ep-4?id=207" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You walk into a system design interview and hear "Design a notification system." Your instinct is to draw a box labeled "Notification API," connect it to a push provider and an email provider, and call it done. That instinct will cost you the round.&lt;/p&gt;

&lt;p&gt;The trap is that "notifications" is not one system—it is two systems running at completely different speeds, and you cannot see that difference until you ask before you draw.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model:&lt;/strong&gt; A notification system is not a delivery system; it is a speed-matcher. Services produce notifications at one rate (unpredictable spikes), providers consume them at wildly different rates (20,000/s push, 200/s SMS), and your job is to decouple those two curves so that a ten-million-message campaign does not leave a password reset waiting fourteen hours.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Four questions before the first box
&lt;/h2&gt;

&lt;p&gt;A real candidate does not guess. They ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What notification types do we send?&lt;/strong&gt; Push, email, SMS, in-app, Slack—each has its own delivery latency and throughput. Push is fast and fire-and-forget. SMS is slow and expensive. Email is somewhere in the middle. You cannot design one queue if the drain rates differ by a factor of a hundred.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What is the expected volume?&lt;/strong&gt; The answer tells you whether to build or to buy. A hundred million notifications a day sounds large until you do the arithmetic: that is roughly 1,160 per second on average. But the average is useless. Peak hour for a social network might be ten times the average. Peak for a campaign is all ten million notifications arriving within minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What is the latency requirement?&lt;/strong&gt; A password reset should arrive in seconds. A marketing email can arrive in hours. A campaign notification can arrive whenever the queue drains it. Latency is not a number; it is a contract that shapes whether you use a queue at all.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What happens if delivery fails?&lt;/strong&gt; Do we retry? How many times? Do we log it and move on? Do we notify the service that sent the notification? This answer determines whether your system is fire-and-forget or transactional, and it changes whether you need a database row per notification.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The number that decides the design
&lt;/h2&gt;

&lt;p&gt;Once you have answers, you size. Assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100 million notifications per day&lt;/li&gt;
&lt;li&gt;50% push, 30% email, 20% SMS (rough split)&lt;/li&gt;
&lt;li&gt;24-hour delivery window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Average per second:
  Push:  100M * 0.5 / 86400 = 578/s
  Email: 100M * 0.3 / 86400 = 347/s
  SMS:   100M * 0.2 / 86400 = 231/s

Peak hour (assume 10x average):
  Push:  5,780/s
  Email: 3,470/s
  SMS:   2,310/s

Provider throughput (from SLAs, not your code):
  Push:  ~20,000/s (Apple, Google, Firebase handle this)
  Email: ~5,000/s (SendGrid, AWS SES have rate limits)
  SMS:   ~200/s (Twilio, carrier agreements are slow)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SMS number is the bottleneck. It is not because your code is slow; it is because the provider is slow. Your system must accept notifications in milliseconds (202 Accepted, queue them, and drain to SMS at 200 per second without blocking services or letting a ten-million-message campaign starve a password reset.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why one queue does not work
&lt;/h2&gt;

&lt;p&gt;You cannot put all three notification types into a single FIFO queue. Here is why:&lt;/p&gt;

&lt;p&gt;Imagine a campaign sends 10 million SMS notifications. They arrive and sit in one queue. A user requests a password reset and gets a notification pushed behind those 10 million campaign messages. At 200 SMS per second, the password reset waits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10,000,000 / 200 = 50,000 seconds = 13.8 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user forgets what they clicked for.&lt;/p&gt;

&lt;p&gt;The fix: separate queues per notification type, each with its own sender that respects the provider's throughput.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Services → Notify API (202 Accepted)
           ↓
        [Notify Store]
           ↓
        ┌──────────────────┐
        │ Push Queue       │ → Push Sender → Push Provider (20,000/s)
        │ Email Queue      │ → Email Sender → Email Provider (5,000/s)
        │ SMS Queue        │ → SMS Sender → SMS Provider (200/s)
        └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each notification type drains at the speed of its provider, and a password reset SMS no longer waits behind a campaign.&lt;/p&gt;




&lt;h2&gt;
  
  
  The API contract
&lt;/h2&gt;

&lt;p&gt;Your &lt;code&gt;/notify&lt;/code&gt; endpoint does not send. It accepts and queues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /notify
Content-Type: application/json

{
  "user_id": "12345",
  "notification_type": "sms",
  "provider": "twilio",
  "template": "password_reset",
  "params": {
    "reset_link": "https://..."
  },
  "retry_policy": "exponential",
  "max_retries": 3
}

→ 202 Accepted
  Location: /notify/event-id-xyz

{
  "event_id": "event-id-xyz",
  "status": "queued",
  "created_at": "2025-01-15T10:23:45Z"
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service gets a 202 in milliseconds. It is decoupled. It does not care whether SMS is slow or push is fast. It submitted the notification and moved on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why you cannot send it inside the request
&lt;/h2&gt;

&lt;p&gt;The interviewer will push back: "A password reset is one API call. Why not just send it inside the &lt;code&gt;POST /login&lt;/code&gt; request? It is simpler."&lt;/p&gt;

&lt;p&gt;This is the moment that separates a rehearsed answer from a real one. Do not say "because decoupling is good" or "because queues are scalable." Say this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On latency:&lt;/strong&gt; If you send SMS inline, the login request blocks until Twilio responds. Twilio is a network hop to their servers, possibly in another region, and SMS is slow—they might batch for throughput. The user waits 2–5 seconds for a success response. The password reset request blocking on SMS delivery is a blast radius violation: a third-party service outage brings down login.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On reliability:&lt;/strong&gt; If the SMS provider times out, the request times out. The client retries. Twilio may have received the message even though the response did not come back (the connection dropped). You now send the same password reset twice. The user gets two SMS.&lt;/p&gt;

&lt;p&gt;Queuing solves both: send a 202 in 5 milliseconds, store the request durably, and retry independently. The provider timeout is a sending problem, not a login problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The retention and idempotency problem
&lt;/h2&gt;

&lt;p&gt;Once you queue, you need to store the notification somewhere. A row per notification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;notification_type&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;template_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_message_id&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row is roughly 200–400 bytes. A hundred million notifications a day is about 20–40 GB. Retention is not free, but it buys you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deduplication:&lt;/strong&gt; If the sender crashes and restarts, it re-reads the queue from the database. It checks &lt;code&gt;provider_message_id&lt;/code&gt;. If the SMS was already sent, it updates the row and moves on. Without this, a crash turns into duplicate notifications.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit trail:&lt;/strong&gt; A customer can ask "did you already send my password reset?" You check the database. You answer yes, the &lt;code&gt;sent_at&lt;/code&gt; was at 10:23 UTC, and the SMS went through to Twilio (we have the Twilio message ID).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Preferences and do-not-contact:&lt;/strong&gt; You can query the user's notification preferences at send time. If the user unsubscribed from marketing emails between the time the notification was queued and the time you try to send it, you do not send it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The provider timeout trap
&lt;/h2&gt;

&lt;p&gt;Here is where candidates break: the SMS provider sends the message, the connection times out, and your code sees an error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_sms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Twilio sends the SMS to the carrier
# Connection drops
# Your code sees: TimeoutError
# Your code retries
# Twilio receives the request again
# Carrier receives the SMS twice
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is idempotency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# UUID, generated once at /notify
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;twilio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_sms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;  &lt;span class="c1"&gt;# Twilio deduplicates on this
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;already_sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider_message_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requeue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_count&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency key is the notification UUID, generated once at &lt;code&gt;/notify&lt;/code&gt;. Twilio stores it, deduplicates on retry, and returns "already sent" instead of sending twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting it together: the data flow
&lt;/h2&gt;

&lt;p&gt;With all pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Service calls &lt;code&gt;POST /notify&lt;/code&gt; with a notification request.&lt;/li&gt;
&lt;li&gt;API generates a UUID, writes the row to the notifications table with status &lt;code&gt;queued&lt;/code&gt;, and returns &lt;code&gt;202 Accepted&lt;/code&gt; in &amp;lt;5ms.&lt;/li&gt;
&lt;li&gt;An async worker reads the &lt;code&gt;queued&lt;/code&gt; rows, groups by notification type, and pushes each batch into its type-specific queue (Redis streams, Kafka, SQS, or a database-backed queue).&lt;/li&gt;
&lt;li&gt;Type-specific sender workers poll their queues, check user preferences, and call the provider API with an idempotency key.&lt;/li&gt;
&lt;li&gt;Provider responds with success or error.&lt;/li&gt;
&lt;li&gt;Sender updates the row: status becomes &lt;code&gt;sent&lt;/code&gt; (or &lt;code&gt;failed&lt;/code&gt; after max retries).&lt;/li&gt;
&lt;li&gt;If a timeout occurs, retry logic uses exponential backoff and the same idempotency key; the provider deduplicates.&lt;/li&gt;
&lt;li&gt;A cleanup job deletes notifications older than the retention window (e.g., 30 days).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Comparison: when to queue vs. when to send inline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Send Inline (Don't Queue)&lt;/th&gt;
&lt;th&gt;Queue (Decouple)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Latency SLA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User must wait for delivery (rare; login, payment confirmation)&lt;/td&gt;
&lt;td&gt;Delivery is best-effort or has a loose SLA (marketing, campaign, most push)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Volume per second&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 100 per second; provider handles sync calls&lt;/td&gt;
&lt;td&gt;&amp;gt; 100 per second; one provider slower than the others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Failure mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client retries the whole request; acceptable&lt;/td&gt;
&lt;td&gt;Client gets 202; retry is independent; provider timeout is not a blast radius&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idempotency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client-side retry is acceptable; user may get duplicates&lt;/td&gt;
&lt;td&gt;Server-side idempotency key prevents duplicates on timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User expectation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synchronous: "did it work?" in the response&lt;/td&gt;
&lt;td&gt;Asynchronous: "submitted" in the response; delivery status checked separately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  LLM inference angle: batch vs. stream
&lt;/h2&gt;

&lt;p&gt;If you are serving notifications to a large language model for personalization (e.g., generating a subject line or body from a template), the same throughput mismatch applies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your notification queue drains to an LLM API at 5,000–10,000 tokens per second (your rate limit or batch size).&lt;/li&gt;
&lt;li&gt;The LLM API handles 50,000+ tokens per second (their scale).&lt;/li&gt;
&lt;li&gt;You are not bottlenecked by the model; you are bottlenecked by your queue or your network.&lt;/li&gt;
&lt;li&gt;A ten-million-message campaign with personalization will fan out to N parallel LLM requests to avoid the batching latency.&lt;/li&gt;
&lt;li&gt;Caching the LLM output per template + user segment reduces the number of API calls; one password reset should not regenerate a subject line if a thousand identical resets came before it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architecture does not change: separate queues, separate senders, idempotency keys, and timeout handling. But the bottleneck moves from the SMS provider to your LLM API, and caching becomes a first-class concern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Reach for queuing and separate per-type senders when notification types have throughput that differs by more than 10× or when services produce notifications in spikes that exceed any single provider's SLA. Reach for inline sending when latency is critical (the client must see success in the response), volume is predictable and low (&amp;lt;100/s), and the provider is fast. In practice, almost all real systems queue; the inline case is rare.&lt;/p&gt;




&lt;p&gt;Watch the 90-second reel for the full walkthrough: &lt;a href="https://youtube.com/shorts/--GXhdPi1zA" rel="noopener noreferrer"&gt;https://youtube.com/shorts/--GXhdPi1zA&lt;/a&gt;&lt;/p&gt;

</description>
      <category>systemdesignmockinterview</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Move the data to the compute, or the compute to the data?</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:43:40 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/move-the-data-to-the-compute-or-the-compute-to-the-data-44ei</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/move-the-data-to-the-compute-or-the-compute-to-the-data-44ei</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/LotX8_V-meo" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/move-the-data-to-the-compute-or-the-compute-to-the-data?id=197" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most cost work arrives too late. A bill lands on someone's desk three months after launch, and the hunt begins: which server can we shrink? But the real number was locked in during a design review, in one sentence about where a computation happens.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model:&lt;/strong&gt; Your bill is not a list of servers. It is a list of decisions about where bytes move.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The concrete job
&lt;/h2&gt;

&lt;p&gt;You have an &lt;code&gt;orders&lt;/code&gt; table. Twelve million rows. One question: what was total revenue this month?&lt;/p&gt;

&lt;p&gt;That single query lives at the exact fork where system design breaks into two paths. You must choose. And the choice you make determines whether you move 2.1 GB across a network boundary or 8 bytes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side A: pull the data back
&lt;/h2&gt;

&lt;p&gt;Fetch the rows. Add them up in your code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT amount FROM orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the version most teams write first, and it deserves real defence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is simple.&lt;/strong&gt; The logic lives in a language you control and test every day. You can unit-test the aggregation function in isolation. You can reason about it. No query planner surprises. No hidden index choice breaking your assumptions next Thursday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is portable.&lt;/strong&gt; Swap the database, swap the schema, swap the cloud. The Python function stays the same. You own the contract between your code and the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is easy to test.&lt;/strong&gt; Mock the result. Inject a list of rows. Verify the sum. Done.&lt;/p&gt;

&lt;p&gt;But here is the cost: on every run, this code moves approximately 2.1 GB across a boundary. The boundary is where the meter sits. Your cloud provider counts bytes crossing that line. Your application needs heap space proportional to the data size—for 12 million rows, that is memory you pay for. Wall-clock time is dominated by transfer, not arithmetic. The addition itself takes microseconds. You are paying for 2.1 GB of I/O to do 2.1 GB ÷ 8 bytes = microseconds of work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side B: push the compute down
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The computation runs where the data already lives. No row transfer. No memory bloat in your service. One number comes back.&lt;/p&gt;

&lt;p&gt;2.1 GB on the wire becomes 8 bytes.&lt;/p&gt;

&lt;p&gt;Identical answer. 288 runs a day. Every run pays for 8 bytes instead of 2.1 GB. The arithmetic happens in the database engine, which is already licensed for that work, already running, already paid for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trade-off table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;tbody&gt;
&lt;tr&gt;
    &lt;th&gt;Criterion&lt;/th&gt;
    &lt;th&gt;Pull the data back (A)&lt;/th&gt;
    &lt;th&gt;Push the compute down (B)&lt;/th&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Network transfer per run&lt;/td&gt;
    &lt;td&gt;~2.1 GB&lt;/td&gt;
    &lt;td&gt;~8 bytes&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Application memory&lt;/td&gt;
    &lt;td&gt;Proportional to data&lt;/td&gt;
    &lt;td&gt;Constant (one result)&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Code testability&lt;/td&gt;
    &lt;td&gt;High—logic in app code&lt;/td&gt;
    &lt;td&gt;Lower—logic in SQL/query planner&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Portability&lt;/td&gt;
    &lt;td&gt;High—language agnostic&lt;/td&gt;
    &lt;td&gt;Coupled to SQL dialect&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Scaling bottleneck&lt;/td&gt;
    &lt;td&gt;Network, app memory&lt;/td&gt;
    &lt;td&gt;Database compute and I/O&lt;/td&gt;
  &lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  This is not a SQL trick
&lt;/h2&gt;

&lt;p&gt;This decision shows up everywhere, wearing different clothes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A view versus a loop in Python&lt;/li&gt;
&lt;li&gt;An aggregate in the database versus a scan in the application&lt;/li&gt;
&lt;li&gt;A join in the database versus a join in your service layer&lt;/li&gt;
&lt;li&gt;Compute co-located with storage versus compute pulling data remotely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every time, the trade-off is identical: simplicity and testability and portability against transfer cost and coupling.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest bill for side B
&lt;/h2&gt;

&lt;p&gt;Pushing computation down is not free. It costs you three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: coupling.&lt;/strong&gt; You are now coupled to that engine. SQL Server has different functions than PostgreSQL. Snowflake has different semantics than DuckDB. Your query must live somewhere, version-controlled and tested, but not in your application code. If you change databases, the query changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: logic out of code you can test.&lt;/strong&gt; When the sum is in your Python function, you own the test. When it is in a SQL query, a planner owns the execution. You cannot easily mock a query planner's choice. You cannot unit-test a query in the same way you unit-test a function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: load on the box hardest to scale sideways.&lt;/strong&gt; Databases scale vertically far more easily than horizontally. A service scales to 100 instances. A database scales to 3 replicas, maybe. If you push computation down, you are adding load to the component that is hardest to scale. If you pull computation back, you scale the service layer, which is easy—add more instances.&lt;/p&gt;

&lt;p&gt;Taken too far, this is how a team ends up with business rules buried in a 400-line query nobody dares change, because nobody can test it in isolation, and the query planner's choices have become a black box.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side A is not wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Side A is a bet.&lt;/strong&gt; It is a bet that the data is small.&lt;/p&gt;

&lt;p&gt;Usually, that is a good bet. When the &lt;code&gt;orders&lt;/code&gt; table has 50,000 rows instead of 12 million, pulling the data back makes sense. The network transfer is 87 MB. The memory footprint fits comfortably. You own the logic. You control the test.&lt;/p&gt;

&lt;p&gt;The failure is not choosing side A.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure is never re-checking the bet after the data grew.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You write side A when you launch. You ship it. Months pass. The dataset grows. Orders accumulate. Suddenly, 50,000 rows becomes 12 million. That query now moves 2.1 GB. Your application crashes because it ran out of memory. Your bill explodes.&lt;/p&gt;

&lt;p&gt;And nobody remembers why side A was chosen. The reasoning lived in someone's head, and that person is on a different team now.&lt;/p&gt;




&lt;h2&gt;
  
  
  One step further: caches and CDNs
&lt;/h2&gt;

&lt;p&gt;A cache and a CDN are this same idea pushed one level further.&lt;/p&gt;

&lt;p&gt;If moving data to compute is one step, then moving compute to data is the step after. But there is a step before &lt;em&gt;that&lt;/em&gt;: stop moving the bytes at all.&lt;/p&gt;

&lt;p&gt;A cache stores the result of a computation locally, near the consumer. When the next request comes in, the bytes are already there. No network. No wait. The cheapest byte is the one you never sent.&lt;/p&gt;

&lt;p&gt;This is why CDNs work. This is why Redis clusters live inside your application network. This is why database read replicas live on the same LAN as your services.&lt;/p&gt;

&lt;p&gt;The principle holds at every scale: bytes crossing a boundary cost money. Bytes that never cross a boundary cost nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  For LLM inference: the same decision under latency
&lt;/h2&gt;

&lt;p&gt;If you are serving LLM inference, data movement is also latency.&lt;/p&gt;

&lt;p&gt;Side A—pulling data back to your inference service—means serializing embeddings or tokens, sending them over the network, loading them into GPU memory, computing a result. Time-to-first-token (TTFT) is high. Your inference service is I/O-bound, not compute-bound.&lt;/p&gt;

&lt;p&gt;Side B—pushing the model to the data—means running inference where the embeddings already live (in a vector database, in a cache). TTFT is lower. Time-per-output-token (TPOT) is more predictable.&lt;/p&gt;

&lt;p&gt;The trade-off is identical: which component carries the load, and is it the one hardest to scale? The principle survives the domain shift.&lt;/p&gt;




&lt;h2&gt;
  
  
  One decision, different clothes
&lt;/h2&gt;

&lt;p&gt;This is why your bill is not a list of servers.&lt;/p&gt;

&lt;p&gt;It is a list of decisions about where bytes move. Every line item on your invoice traces back to a moment in a design review when someone wrote:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Fetch the rows, sum them in the app"&lt;/li&gt;
&lt;li&gt;"Run the aggregate in the database"&lt;/li&gt;
&lt;li&gt;"Cache the result locally"&lt;/li&gt;
&lt;li&gt;"Replicate the data to the edge"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each sentence moved the meter. The server choices that follow are just plumbing. A bigger instance or a smaller one is not the decision. The decision was made when you chose where the computation happens.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reach for side A (pull the data back) when you are confident the dataset will stay small and you need the logic in application code you can test. Reach for side B (push the compute down) when the dataset is large, the query is simple, and the database is your scaling bottleneck anyway. Revisit the bet every time the data grows.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Watch the &lt;a href="https://instagram.com/reels/your-reel-id" rel="noopener noreferrer"&gt;90-second reel&lt;/a&gt; for the full visual breakdown.&lt;/p&gt;

</description>
      <category>systemdesigncost</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Why your index is ignored: expression indexes explained</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 07 Sep 2026 15:38:28 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/why-your-index-is-ignored-expression-indexes-explained-18g9</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/why-your-index-is-ignored-expression-indexes-explained-18g9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/xj2-N4i7HwM" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/why-your-index-is-ignored-expression-indexes-explained?id=196" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have an index on &lt;code&gt;email&lt;/code&gt;. You write what looks like a perfectly ordinary case-insensitive lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'bob@site.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it takes seconds. &lt;code&gt;EXPLAIN&lt;/code&gt; says &lt;code&gt;Seq Scan on users&lt;/code&gt;. The index is right there, and the database is refusing to use it.&lt;/p&gt;

&lt;p&gt;This is not a planner bug, and the index is not "disabled". It is a value mismatch, and once you see it you cannot unsee it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; A B-tree index is a sorted copy of raw column values; applying a function to the column creates a &lt;em&gt;different&lt;/em&gt; value that the index does not contain, so the planner must scan every row instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a B-tree index actually stores
&lt;/h2&gt;

&lt;p&gt;A B-tree index on &lt;code&gt;email&lt;/code&gt; is a sorted tree whose leaves hold the value &lt;strong&gt;exactly as you stored it&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;Alice@Example.com
BOB@site.com
carol@Site.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When your query asks for &lt;code&gt;LOWER(email) = 'bob@site.com'&lt;/code&gt;, the planner checks whether it can navigate the tree using that predicate. But the tree is keyed on the raw email values. The lowercase version—&lt;code&gt;bob@site.com&lt;/code&gt;—is nowhere in the leaves. So the planner cannot use the index. It does the only thing left: read every row from disk, compute &lt;code&gt;LOWER(email)&lt;/code&gt; for each one, and compare.&lt;/p&gt;

&lt;p&gt;On a table with five million rows, that is five million function calls per query. On a write-heavy table, you pay this cost every read.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: expression indexes
&lt;/h2&gt;

&lt;p&gt;An expression index is not a new index type. It is the same B-tree, but keyed on the &lt;strong&gt;result&lt;/strong&gt; of a function instead of a raw column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_lower_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the leaves hold the pre-computed lowercase values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;alice@example.com
bob@site.com
carol@site.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When your query asks for &lt;code&gt;LOWER(email) = 'bob@site.com'&lt;/code&gt;, the planner finds that exact value in the tree. One B-tree lookup. No sequential scan.&lt;/p&gt;

&lt;p&gt;The expression is computed &lt;strong&gt;once, when the row is written&lt;/strong&gt;—on insert or update. The cost is paid upfront, at write time, not amortized across millions of reads.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one rule that decides if it works
&lt;/h2&gt;

&lt;p&gt;The expression in the index must match the expression in the query &lt;strong&gt;exactly&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_lower_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;WHERE LOWER(email) = 'bob@site.com'&lt;/code&gt; uses it. But &lt;code&gt;WHERE UPPER(email) = 'BOB@SITE.COM'&lt;/code&gt; does not—different function, different tree, back to a scan.&lt;/p&gt;

&lt;p&gt;The same principle applies to any expression:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;(data-&amp;gt;&amp;gt;'email')&lt;/code&gt; — a JSONB key extraction&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;date_trunc('day', created_at)&lt;/code&gt; — a timestamp truncation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lower(trim(email))&lt;/code&gt; — a chained function&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;extract(year from order_date)&lt;/code&gt; — date arithmetic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your WHERE clause computes it, you can index it. If the index expression does not match the query expression, the planner will not use it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trade-off: work moves, not vanishes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Aspect&lt;/th&gt;
      &lt;th&gt;Regular Index&lt;/th&gt;
      &lt;th&gt;Expression Index&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Read cost&lt;/td&gt;
      &lt;td&gt;Sequential scan + function on every row&lt;/td&gt;
      &lt;td&gt;One B-tree lookup&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Write cost&lt;/td&gt;
      &lt;td&gt;Store raw value&lt;/td&gt;
      &lt;td&gt;Compute function, store result, maintain index&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Storage&lt;/td&gt;
      &lt;td&gt;Column value only&lt;/td&gt;
      &lt;td&gt;Expression result + index overhead&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;When to use&lt;/td&gt;
      &lt;td&gt;Occasional lookups, write-heavy workload&lt;/td&gt;
      &lt;td&gt;Frequent lookups on the same expression, read-heavy workload&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The work does not disappear. It moves from read time to write time. Every &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt; now runs the expression before writing to disk. In exchange, reads that match that expression become instant.&lt;/p&gt;

&lt;p&gt;This trade-off is worth it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have a read-heavy table.&lt;/li&gt;
&lt;li&gt;You run the same expression in the WHERE clause frequently.&lt;/li&gt;
&lt;li&gt;The cost of computing the expression is non-trivial (like a function call or JSON extraction).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not worth it when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The table is write-heavy and rarely queried with that expression.&lt;/li&gt;
&lt;li&gt;The expression is cheap (like a simple arithmetic operation).&lt;/li&gt;
&lt;li&gt;The table is already under write pressure.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  For LLM inference workloads
&lt;/h2&gt;

&lt;p&gt;If you are serving embedding lookups or vector searches via a relational database (common in RAG and agent frameworks), expression indexes become critical. You often normalize text before embedding lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;TRIM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model_input&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without an expression index, every inference request triggers a full table scan. With one, you move the normalization cost to index write time—a one-time hit per new document ingested, then negligible read latency per inference. On high-throughput inference services, this can be the difference between TTFT under 100ms and over 500ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Reach for a regular index when lookups are rare or writes are frequent; reach for an expression index when you query the same computed value repeatedly and read traffic dominates. The key constraint: the index expression and the query WHERE clause must match exactly, character for character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the 90-second reel&lt;/strong&gt; on Instagram or YouTube for the visual walkthrough: index leaves, the value mismatch, the sequential scan, and the expression index fix drawn in real time.&lt;/p&gt;

</description>
      <category>databasesindexing</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Design a Rate Limiter: The Four-Point Answer, and the Trap</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:53:27 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/design-a-rate-limiter-the-four-point-answer-and-the-trap-3m52</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/design-a-rate-limiter-the-four-point-answer-and-the-trap-3m52</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/2IzBYh2kOIo" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/design-a-rate-limiter-the-four-point-answer-and-the-trap?id=187" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Design a rate limiter."&lt;/p&gt;

&lt;p&gt;It is one of the most common system design interview questions, and it has an unusually reliable failure mode. The candidate hears it as an &lt;em&gt;algorithms&lt;/em&gt; question, names one — token bucket, leaky bucket, fixed window, sliding window log — sketches the mechanism, and stops.&lt;/p&gt;

&lt;p&gt;Everything they said was correct. It also answered the easy half.&lt;/p&gt;

&lt;p&gt;Naming the algorithm tells the interviewer you have read the same article everyone else read. What they are actually listening for starts one question later: &lt;strong&gt;where does the count live when you are running more than one server?&lt;/strong&gt; The algorithm is a function. The counter is state, and state is where system design lives.&lt;/p&gt;

&lt;p&gt;Here is the four-point answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Count against the API key or the user id, not the IP
&lt;/h2&gt;

&lt;p&gt;Before any mechanism, decide what you are counting &lt;em&gt;against&lt;/em&gt;. This is a choice with a wrong answer, not a definition.&lt;/p&gt;

&lt;p&gt;The tempting answer is the IP address, because it is always there and it needs no authentication. The problem is that an IP address is not a person. One IP is routinely one office, one university campus, one coffee shop, one mobile carrier's NAT pool — hundreds or thousands of unrelated people sharing a single address. Rate limit on it and every one of them shares a single budget. The first heavy user of the morning exhausts the quota for the entire building.&lt;/p&gt;

&lt;p&gt;Meanwhile, the abuser you built this for is the one party in the story for whom addresses are cheap and disposable. They rotate through a proxy pool and never touch your ceiling.&lt;/p&gt;

&lt;p&gt;So you punish the innocent and miss the abuser — a limiter that is somehow both too strict and too loose, which is a strange thing to ship.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&amp;nbsp;&lt;/th&gt;
      &lt;th&gt;IP address&lt;/th&gt;
      &lt;th&gt;API key / user id&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;What it identifies&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A network path, at this moment&lt;/td&gt;
      &lt;td&gt;An account — the thing you actually meter&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Who shares one&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;An office, a campus, a carrier NAT pool — thousands of strangers&lt;/td&gt;
      &lt;td&gt;One customer, by construction&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cost to rotate&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Cents, via any proxy pool&lt;/td&gt;
      &lt;td&gt;A new signup, and you can see it happen&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Available before auth&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Stable across networks&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;No — wifi to cellular changes it&lt;/td&gt;
      &lt;td&gt;Yes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Failure mode&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Throttles the innocent, misses the abuser&lt;/td&gt;
      &lt;td&gt;Needs an identity to exist first&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Count against the identity: the API key, the user id, the tenant. That is stable across networks, it is the thing your product actually meters, and it is the thing a customer would recognise on an invoice.&lt;/p&gt;

&lt;p&gt;There is one honest exception. &lt;strong&gt;Anonymous, unauthenticated traffic&lt;/strong&gt; — a public signup endpoint, a login form, an unauthenticated search — has no identity to count against yet. There, the IP is the best signal available and an IP limit is the right call. Say that out loud in the interview; knowing when the weaker key is the correct key is worth more than rejecting it outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Enforce it at the gateway, not inside every service
&lt;/h2&gt;

&lt;p&gt;Put the limiter at the front door — the API gateway, the edge proxy, the ingress — and not inside each service behind it.&lt;/p&gt;

&lt;p&gt;Two reasons, and they are different in kind.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;operational&lt;/strong&gt; one: one front door is one place where limits are configured, one place where they are changed, one place to look when a customer says they are being throttled. Implement the check in each service and you have N implementations of the same rule, which will drift, and a limit change becomes N deploys.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;economic&lt;/strong&gt; one is stronger. A request rejected at the door never gets past the door. It does not open a database connection, does not enqueue a job, does not hit your billing code, does not consume a worker slot. That is the entire point of rate limiting under load: the traffic you refuse must be &lt;em&gt;cheap to refuse&lt;/em&gt;. A limiter that sits deep in the stack — say, as a decorator on a service method — has already paid for connection setup, deserialisation and authentication before it decides the answer is no. Under the exact conditions you built it for, it costs you the most.&lt;/p&gt;

&lt;p&gt;There is a second-order version of the same mistake: a request that fails the check in service C has already done real work in A and B.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The counter is shared state
&lt;/h2&gt;

&lt;p&gt;This is the point most answers miss, and it is where the question is actually decided.&lt;/p&gt;

&lt;p&gt;Say you keep the count in a dictionary in the API process. It is fast, it needs no extra infrastructure, and it works perfectly in development, where you run one process.&lt;/p&gt;

&lt;p&gt;Then you run three.&lt;/p&gt;

&lt;p&gt;You wrote "100 requests per minute" in the spec. What you have built is &lt;strong&gt;100 requests per minute per instance&lt;/strong&gt;. Three instances is 300. Ten is 1000. And the number is not even fixed — a load balancer spreading a user's requests across the fleet means each instance sees roughly a third of that user's traffic and never trips, so in practice the ceiling is higher still and depends on how the balancer happens to route.&lt;/p&gt;

&lt;p&gt;Then the autoscaler joins in. Now the effective limit &lt;strong&gt;moves with your traffic&lt;/strong&gt;: busy hour, more instances, higher real limit — precisely the opposite of what a limiter is for. The system is most permissive at the moment it should be strictest, and nothing in your monitoring will say so, because every individual instance is dutifully enforcing 100.&lt;/p&gt;

&lt;p&gt;The fix is not a cleverer algorithm. It is &lt;strong&gt;one store, one atomic increment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One store: a single place — Redis is the usual choice — that every instance talks to, so there is exactly one number per key per window.&lt;/p&gt;

&lt;p&gt;One atomic increment: not read, then decide, then write. Those are three steps, and two requests arriving together can both read 99, both decide they are allowed, and both write 100. The check and the claim have to be a single indivisible operation. Whether that is &lt;code&gt;INCR&lt;/code&gt; with an expiry, a conditional decrement, or a small server-side script is an implementation detail; the invariant is what earns the point.&lt;/p&gt;

&lt;p&gt;That store is now on the hot path of every single request, so say the consequences out loud too: it needs to be fast, it needs to be close, and you need an answer for what happens when it is unreachable. Fail open and you have no limiter during exactly the incident that produced the outage. Fail closed and a limiter outage is a full outage. Choosing deliberately, and being able to say which way you chose and why, is the senior answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Answer with a 429, a Retry-After, and the quota left
&lt;/h2&gt;

&lt;p&gt;The last point is the contract, and it is the one candidates skip because it feels like trivia.&lt;/p&gt;

&lt;p&gt;Reject with &lt;strong&gt;429 Too Many Requests&lt;/strong&gt;. Include &lt;strong&gt;Retry-After&lt;/strong&gt; with the number of seconds until the window resets. Include the remaining quota — &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, or whatever your API already uses — on the successful responses too, so a well-behaved client can see the wall coming before it hits it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt; &lt;span class="ne"&gt;Too Many Requests&lt;/span&gt;
&lt;span class="na"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12&lt;/span&gt;
&lt;span class="na"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this matters is not politeness. It is that the client now has &lt;strong&gt;a number to wait on instead of a guess&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A limiter that returns a bare 500, or a 403, or a 200 with an error body, teaches every client integrating against you to invent its own retry timing — and the timings they invent are uniformly worse than the one you already know, because you are the only party who knows when the window resets. You are handing out a fact you have and they do not. Withholding it costs you the retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same question, in front of a model
&lt;/h2&gt;

&lt;p&gt;If you have ever been handed an LLM API key, you have already met all four points wearing different names — and one extra twist that makes this the sharpest live example of the pattern.&lt;/p&gt;

&lt;p&gt;Look at how a model provider states its limits. Not "100 requests per minute" but &lt;strong&gt;RPM &lt;em&gt;and&lt;/em&gt; TPM&lt;/strong&gt; — requests per minute and &lt;em&gt;tokens&lt;/em&gt; per minute, enforced together. That second dimension breaks an assumption the classic answer quietly makes: that every request costs the same. Here they do not. One request is forty tokens; the next drags a 100,000-token document behind it.&lt;/p&gt;

&lt;p&gt;So the count is no longer &lt;code&gt;+1&lt;/code&gt;. It is &lt;code&gt;+cost&lt;/code&gt;, and you do not know the cost until you have counted the prompt — and you do not know the &lt;em&gt;full&lt;/em&gt; cost until the model has finished generating, because output tokens count too. Real limiters handle this by &lt;strong&gt;reserving an estimate up front and reconciling on completion&lt;/strong&gt;: increment by prompt tokens plus a max-output allowance, then correct the counter down when the stream ends. That is still one store and one atomic operation. It is just an operation that adds a number rather than one.&lt;/p&gt;

&lt;p&gt;The other three points transfer unchanged, and get &lt;em&gt;more&lt;/em&gt; important rather than less:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What you count against&lt;/strong&gt; — the tenant, not the IP, because one of your customers is a chat product with ten thousand users behind one egress address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where you enforce it&lt;/strong&gt; — at the gateway in front of the model, because a rejected inference request must never reach the GPU. This is the economic argument at its most extreme: a request admitted by mistake does not cost you a database connection, it occupies a slot on a machine that costs dollars an hour, for seconds at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reply&lt;/strong&gt; — 429 with a &lt;code&gt;Retry-After&lt;/code&gt;. Every major provider sends one, and every serious client library reads it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you are serving your &lt;em&gt;own&lt;/em&gt; model rather than calling someone else's, the scarce resource shifts again: what actually runs out is not requests per minute but &lt;strong&gt;concurrent sequences in the batch and blocks in the KV cache&lt;/strong&gt;. A limiter in front of an inference server is really admission control for GPU memory, and the honest version of it counts what the GPU is short of rather than what is easy to count.&lt;/p&gt;

&lt;p&gt;Which is the same lesson as point 3, one level up: the interesting part was never the algorithm. It was working out what the number means and where it is kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Everyone answers "token bucket". The interviewer is waiting to hear where the count lives.&lt;/p&gt;

&lt;p&gt;That is the whole question, compressed. The algorithm is a small, well-documented function that you could look up in a minute. The counter is distributed mutable state on the hot path of every request — which is a system design problem, which is why this is a system design interview.&lt;/p&gt;

&lt;p&gt;If you have time for one more sentence after the four points, make it this one: the algorithm decides &lt;em&gt;whether&lt;/em&gt; to allow a request, and the storage decides whether that decision is &lt;em&gt;true&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict — the whole answer in four lines
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;key&lt;/strong&gt; → count per user or API key, not per IP; IP only for anonymous traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gateway&lt;/strong&gt; → one front door, so a rejected request costs nothing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;counter&lt;/strong&gt; → one shared store, one atomic increment, because in-process counts multiply by your instance count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;429&lt;/strong&gt; → plus Retry-After and the remaining quota, so the client waits on a number instead of a guess&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Name an algorithm if you like — token bucket is a fine choice and takes five seconds to justify. Then spend the rest of your answer on the four points above, because that is the part the interviewer cannot look up on your behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the reply contract — point 4&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IETF, &lt;em&gt;RFC 6585 §4 — Additional HTTP Status Codes: 429 Too Many Requests&lt;/em&gt; (IETF, 2012) — the normative definition of the status code: the server indicates the user has sent too many requests in a given amount of time, and the response may include a &lt;code&gt;Retry-After&lt;/code&gt;. Worth quoting in an interview because the spec explicitly declines to define &lt;em&gt;how&lt;/em&gt; the server counts, which is exactly the storage decision this article argues you are being tested on: &lt;a href="https://www.rfc-editor.org/rfc/rfc6585#section-4" rel="noopener noreferrer"&gt;rfc-editor.org/rfc/rfc6585#section-4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;IETF, &lt;em&gt;RFC 9110 §10.2.3 — HTTP Semantics: the Retry-After header field&lt;/em&gt; (IETF, 2022) — the support for "a number to wait on, not a guess": &lt;code&gt;Retry-After&lt;/code&gt; carries either a delay in seconds or an HTTP-date after which the client may retry, and nothing else: &lt;a href="https://www.rfc-editor.org/rfc/rfc9110#field.retry-after" rel="noopener noreferrer"&gt;rfc-editor.org/rfc/rfc9110#field.retry-after&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;IETF, &lt;em&gt;draft-ietf-httpapi-ratelimit-headers — RateLimit header fields for HTTP&lt;/em&gt; (IETF, in progress) — the ongoing standardisation of the "quota remaining" half of point 4, and the reason the &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers above are a de-facto convention rather than a standard. Note that it is a draft, not a published RFC: &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/" rel="noopener noreferrer"&gt;datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the shared counter — point 3&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis documentation, &lt;em&gt;INCR — "Pattern: Rate limiter"&lt;/em&gt; — the concrete mechanism behind "one store, one atomic increment", from the store this design would actually use: a counter per key per window, incremented atomically and expired with the window. It also documents the exact race the wording above is chosen to avoid — the gap between the &lt;code&gt;INCR&lt;/code&gt; and the &lt;code&gt;EXPIRE&lt;/code&gt;: &lt;a href="https://redis.io/docs/latest/commands/incr/" rel="noopener noreferrer"&gt;redis.io/docs/latest/commands/incr&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On what to count and where to enforce it — points 1 and 2&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer, Chris Jones, Jennifer Petoff and Niall Richard Murphy (eds.), &lt;em&gt;Site Reliability Engineering&lt;/em&gt;, chapter 21, "Handling Overload" (O'Reilly / Google, 2016) — Google's practice of metering per customer against a quota rather than per source address, and the argument that a rejected request is not free: it still costs CPU and memory, so it must be refused as early and as cheaply as possible. That is the economic case for the front door.&lt;/li&gt;
&lt;li&gt;Paul Tarjan, &lt;em&gt;Scaling your API with rate limiters&lt;/em&gt; (Stripe Engineering blog, 2017) — a production account that lines up with points 1, 2 and 4: limits applied per API key at the edge rather than inside application code, backed by a shared Redis counter, returning 429 with enough information for the caller to act: &lt;a href="https://stripe.com/blog/rate-limiters" rel="noopener noreferrer"&gt;stripe.com/blog/rate-limiters&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/2IzBYh2kOIo" rel="noopener noreferrer"&gt;Design a rate limiter — the four-point answer, and the trap&lt;/a&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>interview</category>
    </item>
    <item>
      <title>MCP vs API: Why Your Server Needs a /mcp Endpoint (and What You Actually Write)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 31 Aug 2026 15:25:57 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/mcp-vs-api-why-your-server-needs-a-mcp-endpoint-and-what-you-actually-write-4mk6</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/mcp-vs-api-why-your-server-needs-a-mcp-endpoint-and-what-you-actually-write-4mk6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/9Jf1zm2MDOA" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/mcp-vs-api-why-your-server-needs-a-mcp-endpoint-and-what-you-actually-write?id=186" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Same server. Same database. Same functions.
&lt;/h2&gt;

&lt;p&gt;Your service already has an API. It has had one for years. It is documented, it is authenticated, it is in production, and it works.&lt;/p&gt;

&lt;p&gt;So why is everyone suddenly standing up a &lt;code&gt;/mcp&lt;/code&gt; endpoint right next to it?&lt;/p&gt;

&lt;p&gt;Not because the old one is broken. Behind both doors is the same server, the same database, the same Python functions. Nothing underneath changes. The difference is one sentence long:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The API is written for a developer. The MCP endpoint is written for a model.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else in this article is a consequence of that sentence — including the one part of the job that no library will do for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a plain endpoint is not enough
&lt;/h2&gt;

&lt;p&gt;Think about how your API actually got called.&lt;/p&gt;

&lt;p&gt;Somewhere, a human being opened your documentation. They read that the path is &lt;code&gt;/v1/invoices&lt;/code&gt;, that it takes a &lt;code&gt;customer_id&lt;/code&gt; and a &lt;code&gt;from&lt;/code&gt; and a &lt;code&gt;to&lt;/code&gt;, that the dates are ISO-8601 and that the response is paginated. Then they went back to their editor and hard-coded that knowledge into a client.&lt;/p&gt;

&lt;p&gt;All of the understanding happened &lt;strong&gt;outside the wire&lt;/strong&gt;. The endpoint itself never explained anything. Ask it what it is for and it has no answer — there is no request you can send it that means &lt;em&gt;"describe yourself."&lt;/em&gt; It was never designed to have one, because the reader was always going to be a person with a browser.&lt;/p&gt;

&lt;p&gt;A model does not have a browser, and it was not in the room when your docs were read. If the only thing on the wire is &lt;code&gt;POST /v1/invoices&lt;/code&gt;, the model is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP changes is the first move
&lt;/h2&gt;

&lt;p&gt;Here is the actual shift, and it is smaller than it sounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With an API&lt;/strong&gt;, the first thing that happens on the wire is a call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With MCP&lt;/strong&gt;, the first thing that happens on the wire is a &lt;em&gt;question&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before invoking anything, the client asks the server: &lt;strong&gt;what can you do?&lt;/strong&gt; The server answers with a list. Only then does the model pick something from that list and call it.&lt;/p&gt;

&lt;p&gt;Discovery first, invocation second. That ordering is the protocol's whole reason to exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. client → server   tools/list      "what can you do?"
2. server → client   [ 8 tools, each with a description and a schema ]
3. client → server   tools/call      "do this one, with these arguments"
4. server → client   the result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1 and 2 are the part your REST API has never had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing every REST developer notices first
&lt;/h2&gt;

&lt;p&gt;Open the traffic and something looks wrong: &lt;strong&gt;there is no resource path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your API puts the noun in the URL and the verb in the HTTP method — &lt;code&gt;GET /invoices/42&lt;/code&gt;, &lt;code&gt;DELETE /invoices/42&lt;/code&gt;. MCP does neither. There is one path, &lt;code&gt;/mcp&lt;/code&gt;, and every single message is a &lt;code&gt;POST&lt;/code&gt; to it.&lt;/p&gt;

&lt;p&gt;That is because MCP speaks &lt;strong&gt;JSON-RPC 2.0&lt;/strong&gt;. The operation is not in the URL and not in the method — it is in the &lt;strong&gt;body&lt;/strong&gt;, in a field called &lt;code&gt;method&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/mcp&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Authorization:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Bearer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;token&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Content-Type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;application/json&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reply is a menu — and this is the interesting part. Each entry carries a name, a plain-English description of what it is for, and a schema in which &lt;strong&gt;every parameter has its own description&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_invoices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Find a customer's invoices for a given year. Use this when someone asks about billing history, unpaid bills, or what a customer was charged."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Customer name or account ID, e.g. 'ACME Ltd' or 'cus_8812'"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Four-digit calendar year, e.g. 2026"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is documentation, delivered as &lt;strong&gt;data&lt;/strong&gt;, over the same wire the call goes out on. Nobody had to read anything in a browser.&lt;/p&gt;

&lt;p&gt;Calling goes through the same door. Only the &lt;code&gt;method&lt;/code&gt; changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tools/call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_invoices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACME Ltd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it is authenticated exactly the way your API is — a bearer token on every request. This is not a new security model. It is your security model, on a new endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  You never write that JSON by hand
&lt;/h2&gt;

&lt;p&gt;Look at that schema again and the obvious objection is: &lt;em&gt;nobody is maintaining that.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Correct. You do not write it. &lt;strong&gt;FastMCP derives it from code you were already writing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Customer name or account ID, e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ACME Ltd&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cus_8812&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
    &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Four-digit calendar year, e.g. 2026&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find a customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s invoices for a given year.

    Use this when someone asks about billing history, unpaid bills,
    or what a customer was charged.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three mappings, and that is the whole trick:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What you write in Python&lt;/th&gt;
      &lt;th&gt;What the model receives&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Function name &lt;code&gt;find_invoices&lt;/code&gt;
&lt;/td&gt;
      &lt;td&gt;The tool &lt;code&gt;name&lt;/code&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The docstring&lt;/td&gt;
      &lt;td&gt;The tool &lt;code&gt;description&lt;/code&gt; — what it is for and when to reach for it&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Type hints + &lt;code&gt;Field(description=...)&lt;/code&gt;
&lt;/td&gt;
      &lt;td&gt;The &lt;code&gt;inputSchema&lt;/code&gt;, one described parameter at a time&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which has a consequence worth sitting with for a second.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your docstring is no longer a comment. It is a prompt.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is no longer read by a teammate skimming the file. It is read by the thing deciding whether to call your function at all, and with what. A vague docstring is now a runtime failure mode, not a code-review nit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not generated: the tools themselves
&lt;/h2&gt;

&lt;p&gt;Here is where most first MCP servers go wrong, and it is the reason this article exists.&lt;/p&gt;

&lt;p&gt;Because FastMCP generates the schema, it is tempting to conclude that the whole job is generated — point something at your OpenAPI spec, get an MCP server out, ship it. You will get a server. You will not get a usable one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The schema is generated. The tool design is not.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MCP is &lt;strong&gt;not a thin 1:1 layer over your existing endpoints.&lt;/strong&gt; You write a &lt;strong&gt;new tool per capability&lt;/strong&gt;, and the good ones are shaped like &lt;strong&gt;tasks&lt;/strong&gt;, not like resources.&lt;/p&gt;

&lt;p&gt;That distinction is concrete. Your REST API is decomposed the way a database is: &lt;code&gt;/customers&lt;/code&gt;, &lt;code&gt;/invoices&lt;/code&gt;, &lt;code&gt;/line-items&lt;/code&gt;, each with filters, each returning a page. Answering &lt;em&gt;"what did ACME pay us in 2026?"&lt;/em&gt; takes three calls and some glue — the human client author wrote that glue once and forgot about it.&lt;/p&gt;

&lt;p&gt;A tool has no glue author. So the tool is the whole task:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;/th&gt;
      &lt;th&gt;Auto-generated from the spec&lt;/th&gt;
      &lt;th&gt;Written by hand&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Unit of design&lt;/td&gt;
      &lt;td&gt;One tool per endpoint / per resource&lt;/td&gt;
      &lt;td&gt;One tool per &lt;strong&gt;task a user actually asks for&lt;/strong&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Typical count&lt;/td&gt;
      &lt;td&gt;60+&lt;/td&gt;
      &lt;td&gt;8&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Answering "what did ACME pay in 2026?"&lt;/td&gt;
      &lt;td&gt;3 chained calls, IDs threaded by the model&lt;/td&gt;
      &lt;td&gt;1 call: &lt;code&gt;find_invoices(customer, year)&lt;/code&gt;
&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Descriptions&lt;/td&gt;
      &lt;td&gt;Inherited from a spec written for humans, or empty&lt;/td&gt;
      &lt;td&gt;Written for the decision the model has to make&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Parameters&lt;/td&gt;
      &lt;td&gt;Every filter the endpoint supports, most of them noise&lt;/td&gt;
      &lt;td&gt;Only what the task needs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Failure mode&lt;/td&gt;
      &lt;td&gt;Model picks the wrong one of six similar tools, or gives up&lt;/td&gt;
      &lt;td&gt;Model picks correctly, because the choice is obvious&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Point a generator at your OpenAPI spec and you get &lt;strong&gt;60 tools nobody can use&lt;/strong&gt;. Write &lt;strong&gt;8 by hand&lt;/strong&gt;, with real docstrings, and the model uses them correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bites harder than it looks (the AI-engineering angle)
&lt;/h2&gt;

&lt;p&gt;If you have only built request/response systems, the cost of a bad tool surface is not obvious. It is not a latency problem. It is a &lt;strong&gt;context and decision problem&lt;/strong&gt;, and it shows up in three places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every tool is permanently in the prompt.&lt;/strong&gt; The tool list is not fetched when needed — it is serialised into the model's context on &lt;em&gt;every single turn&lt;/em&gt; of the conversation. Sixty tools with full schemas is thousands of tokens spent before the user has said anything, on every message, forever. Eight task-shaped tools cost a fraction of that, and leave the budget for the actual work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every extra tool is another chance to choose wrong.&lt;/strong&gt; Selection accuracy degrades as near-duplicates pile up. Six endpoints that all list something, with descriptions inherited from a spec written for a human who already knew which one they wanted, is a menu designed to be misread. This is the same reason a chained workflow is fragile: three dependent calls means three chances to thread the wrong ID, and one wrong ID is a confidently wrong answer rather than an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The description is the interface.&lt;/strong&gt; In a normal API, the contract is the signature and the docs are advisory. Here the prose &lt;em&gt;is&lt;/em&gt; the routing logic — a tool whose description does not say &lt;strong&gt;when to use it&lt;/strong&gt; will be called at the wrong moment, no matter how correct its implementation is. Write descriptions that answer the model's actual question ("is this the one?"), not the ones that restate the function name.&lt;/p&gt;

&lt;p&gt;Which gives you a useful design test. For each tool, ask: &lt;strong&gt;is this a thing a user would ask for in a sentence?&lt;/strong&gt; &lt;code&gt;find_invoices(customer, year)&lt;/code&gt; passes. &lt;code&gt;list_line_items(invoice_id, page, per_page, sort)&lt;/code&gt; does not — it is an implementation detail that leaked onto the menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Adding MCP is not rewriting your product, and it is not flipping a switch either.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep everything below the surface: your queries, your business logic, your auth, your database. None of it moves. The REST API stays exactly where it is, serving the clients it already serves — MCP does not replace it, it sits beside it.&lt;/p&gt;

&lt;p&gt;What is genuinely new is a &lt;strong&gt;tool surface&lt;/strong&gt;, and that surface is a design artifact you author. The protocol gives you discovery. FastMCP gives you the schema. The judgment about &lt;em&gt;which capabilities exist and what shape they have&lt;/em&gt; is the part that is still yours, and it is the part that decides whether the thing works.&lt;/p&gt;

&lt;p&gt;The short version, if you take one line away: &lt;strong&gt;the schema is generated for you; the tool design is not.&lt;/strong&gt; Write eight tools shaped like the questions people ask, give each a docstring you would be happy to have read aloud as an instruction, and you are done.&lt;/p&gt;

&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the protocol itself — discovery, and the JSON-RPC envelope&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model Context Protocol, &lt;em&gt;Specification&lt;/em&gt; — the normative definition of &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt;, the tool object with its &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;inputSchema&lt;/code&gt;, and the transport and authorization sections behind the bearer-token point above: &lt;a href="https://modelcontextprotocol.io/specification" rel="noopener noreferrer"&gt;modelcontextprotocol.io/specification&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;JSON-RPC Working Group, &lt;em&gt;JSON-RPC 2.0 Specification&lt;/em&gt; (2010) — the envelope MCP is built on: why the operation lives in a &lt;code&gt;method&lt;/code&gt; field inside the body rather than in a URL or an HTTP verb, and how &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;params&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; fit together: &lt;a href="https://www.jsonrpc.org/specification" rel="noopener noreferrer"&gt;jsonrpc.org/specification&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On why a REST API looks so different — the noun in the URL&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Roy T. Fielding, &lt;em&gt;Architectural Styles and the Design of Network-based Software Architectures&lt;/em&gt; (PhD dissertation, UC Irvine, 2000), ch. 5 — the resource/identifier/uniform-interface model that puts the noun in the path and the verb in the method, which is exactly the convention MCP declines to use: &lt;a href="https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm" rel="noopener noreferrer"&gt;ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAPI Initiative, &lt;em&gt;OpenAPI Specification&lt;/em&gt; — what an endpoint-shaped description of a service actually contains (paths, operations, parameters), and therefore what a generator pointed at one can and cannot know about the tasks a user wants to perform: &lt;a href="https://spec.openapis.org/oas/latest.html" rel="noopener noreferrer"&gt;spec.openapis.org/oas/latest.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On generating the schema from code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;FastMCP documentation&lt;/em&gt; — the &lt;code&gt;@mcp.tool&lt;/code&gt; decorator and the derivation rules used above: function name to tool name, docstring to description, type hints to &lt;code&gt;inputSchema&lt;/code&gt;: &lt;a href="https://gofastmcp.com" rel="noopener noreferrer"&gt;gofastmcp.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Pydantic documentation, &lt;em&gt;Fields&lt;/em&gt; and &lt;em&gt;JSON Schema&lt;/em&gt; — how &lt;code&gt;Field(description=...)&lt;/code&gt; and annotated types become the per-parameter descriptions the model reads, which is where most of the usable signal in a tool schema comes from: &lt;a href="https://docs.pydantic.dev/latest/concepts/fields/" rel="noopener noreferrer"&gt;docs.pydantic.dev/latest/concepts/fields/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the part that is not generated — designing the tools&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic, &lt;em&gt;Writing effective tools for agents&lt;/em&gt; (Anthropic Engineering, 2025) — the case for consolidating several low-level endpoints into one task-shaped tool, for spending real effort on tool descriptions, and for evaluating a tool surface rather than assuming it: &lt;a href="https://www.anthropic.com/engineering/writing-tools-for-agents" rel="noopener noreferrer"&gt;anthropic.com/engineering/writing-tools-for-agents&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the short:&lt;/strong&gt; &lt;a href="https://youtu.be/9Jf1zm2MDOA" rel="noopener noreferrer"&gt;MCP vs API — Why Your Server Needs a /mcp Endpoint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep going:&lt;/strong&gt; the rest of the free AI-engineering course lives at &lt;a href="https://software-engineer-blog.com/ai" rel="noopener noreferrer"&gt;software-engineer-blog.com/ai&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>programming</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>RTO vs RPO: The Two Numbers That Decide What Disaster Recovery Costs</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 31 Aug 2026 07:34:53 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/rto-vs-rpo-the-two-numbers-that-decide-what-disaster-recovery-costs-5afn</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/rto-vs-rpo-the-two-numbers-that-decide-what-disaster-recovery-costs-5afn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/koRSzvlIX1Q" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/rto-vs-rpo-the-two-numbers-that-decide-what-disaster-recovery-costs?id=185" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two letters apart. Both measured in minutes. Bought with completely different money.&lt;/p&gt;

&lt;p&gt;That is why teams argue about disaster recovery for a week without noticing they are talking about two separate things. This article takes the two numbers apart, and then prices both of them against one small shop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tuesday, twelve minutes past three
&lt;/h2&gt;

&lt;p&gt;The server that holds every order for an online shop is gone.&lt;/p&gt;

&lt;p&gt;The alert fires. Somebody wakes up. By twenty past three they have found the most recent saved copy of the data. That copy was taken at three, on the hour. So they build a new server, and they put that copy onto it. At nineteen minutes past four, the shop can take orders again.&lt;/p&gt;

&lt;p&gt;Now count what actually happened.&lt;/p&gt;

&lt;p&gt;The shop was shut for &lt;strong&gt;sixty-seven minutes&lt;/strong&gt;. And &lt;strong&gt;twelve minutes of orders&lt;/strong&gt; — the ones taken between three o'clock and the moment it died — do not exist anywhere any more.&lt;/p&gt;

&lt;p&gt;Two different things went wrong.&lt;/p&gt;

&lt;p&gt;For the next week, the team argued about how to make sure it never happened again. And in that whole week, nobody ever said which of the two numbers they were trying to make smaller. So which one was it? The sixty-seven minutes the shop was shut, or the twelve minutes of orders that vanished?&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three things are covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What the two numbers actually are. One is how long you may be &lt;strong&gt;down&lt;/strong&gt;. The other is how much data you may &lt;strong&gt;lose&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;What each one is bought with, because they are not bought with the same money.&lt;/li&gt;
&lt;li&gt;How the standard tiers are priced, and where that price stops being reasonable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three things are not covered. This is not how backups work on the inside — there are &lt;strong&gt;no restore steps here&lt;/strong&gt;, and that is a subject for its own article. This is not about which site serves your traffic or how failover flips between two sites, which is covered elsewhere on this site. And this is not one product: &lt;strong&gt;no tool and no cloud provider is named anywhere below.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Two dials, not one
&lt;/h2&gt;

&lt;p&gt;Here is the confusion this kills. People hear "disaster recovery" and think there is one dial, and that turning it up makes everything better.&lt;/p&gt;

&lt;p&gt;There are two dials. A shop that catches fire is the easiest way to see them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First question. How long is the shop shut?&lt;/strong&gt; The fire is out, but the doors still do not open. Every hour shut is customers who go somewhere else. You make that shorter by renting a second shop, already fitted out and ready. And a second shop costs rent every single month, whether or not there is ever a fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second question. How many pages of the order book burned?&lt;/strong&gt; The shop can open again, but some orders simply do not exist. You lost every order taken since the last time somebody carried a page across the road to the safe. You make that shorter by copying more often. And copying after every single order slows down every single order you ever take.&lt;/p&gt;

&lt;p&gt;Now notice how independent those two are. You could reopen in an hour with a whole day of orders missing. You could lose nothing at all and stay shut for a week. Both are possible, because these are two different numbers.&lt;/p&gt;




&lt;h2&gt;
  
  
  One line of time, two arrows
&lt;/h2&gt;

&lt;p&gt;Draw the life of the failure as a line of time. Mark the moment it broke. Everything to the left already happened. Everything to the right has not happened yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look backwards&lt;/strong&gt; from the break, to the last moment your data was safely saved somewhere else. That distance is the &lt;strong&gt;recovery point objective&lt;/strong&gt;, or RPO. It is the data you are willing to lose. On Tuesday it was twelve minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look forwards&lt;/strong&gt; from the break, to the moment a customer could place an order again. That distance is the &lt;strong&gt;recovery time objective&lt;/strong&gt;, or RTO. It is the time you are willing to be down. On Tuesday it was sixty-seven minutes.&lt;/p&gt;

&lt;p&gt;Same instant. Two arrows. They point in opposite directions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;RPO&lt;/th&gt;
&lt;th&gt;RTO&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Which way it points&lt;/td&gt;
&lt;td&gt;backwards, into the data&lt;/td&gt;
&lt;td&gt;forwards, into the clock&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What it measures&lt;/td&gt;
&lt;td&gt;work that vanished&lt;/td&gt;
&lt;td&gt;time the doors were shut&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What sets it&lt;/td&gt;
&lt;td&gt;how often you copy&lt;/td&gt;
&lt;td&gt;how fast you can be serving again&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;On Tuesday&lt;/td&gt;
&lt;td&gt;12 minutes&lt;/td&gt;
&lt;td&gt;67 minutes&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both are measured in minutes, and that is exactly why people mix them up. Every section below is this same line with the two marks moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  RPO: the promise is the worst case
&lt;/h2&gt;

&lt;p&gt;Suppose you save a copy of the data every fifteen minutes. What have you actually promised?&lt;/p&gt;

&lt;p&gt;Here is the whole thing, in a few lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rpo.py -- what does "we copy every fifteen minutes" really promise?
&lt;/span&gt;&lt;span class="n"&gt;COPY_EVERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;      &lt;span class="c1"&gt;# seconds between one saved copy and the next
&lt;/span&gt;&lt;span class="n"&gt;ORDERS_PER_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;       &lt;span class="c1"&gt;# this shop, on an ordinary day
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_copy_at&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;failed_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_copy_at&lt;/span&gt;    &lt;span class="c1"&gt;# everything in the gap is gone
&lt;/span&gt;
&lt;span class="c1"&gt;# The failure does not check your schedule. It lands anywhere in the gap.
&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;899&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_copy_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# a second before the next copy
&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="n"&gt;last_copy_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# a second after the last one
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# 899 1  -&amp;gt; the whole gap, or almost nothing
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ORDERS_PER_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# 749.2  -&amp;gt; orders that never existed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything written between the last saved copy and the failure is gone. That is the definition, and it is the only thing that matters here.&lt;/p&gt;

&lt;p&gt;Now, the failure does not check your schedule before it happens. It can land anywhere inside that gap. If it lands one second before the next copy was due, you lose fourteen minutes and fifty-nine seconds of work. If it lands one second after a copy, you lose one second. Both are possible on the same schedule.&lt;/p&gt;

&lt;p&gt;And here is the trap. &lt;strong&gt;The number you promise the business is the worst case, never the average.&lt;/strong&gt; A copy every fifteen minutes means fifteen minutes.&lt;/p&gt;

&lt;p&gt;This shop takes fifty orders a minute, and an order is worth thirty-eight euros. So the same schedule, said honestly, looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;How often you copy&lt;/th&gt;
&lt;th&gt;Worst case&lt;/th&gt;
&lt;th&gt;Orders that never existed&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Once a night&lt;/td&gt;
&lt;td&gt;23 h 59 min 59 s&lt;/td&gt;
&lt;td&gt;71,999&lt;/td&gt;
&lt;td&gt;EUR 2,735,968&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Every 15 minutes&lt;/td&gt;
&lt;td&gt;14 min 59 s&lt;/td&gt;
&lt;td&gt;749&lt;/td&gt;
&lt;td&gt;EUR 28,468&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;As each order is taken, about 2 s behind&lt;/td&gt;
&lt;td&gt;2 seconds&lt;/td&gt;
&lt;td&gt;1.67&lt;/td&gt;
&lt;td&gt;EUR 63&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at what changed between those three rows. Not the quality of the copies. Not the storage. Not the restore. &lt;strong&gt;The only thing that changed is how often.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the only lever RPO has, and every step of it costs you something on the way in. Which is why arguing about backup software rarely moves this number at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  RTO is a sum, and the restore is the small term
&lt;/h2&gt;

&lt;p&gt;Now the other number, and it has a trap of its own.&lt;/p&gt;

&lt;p&gt;Ask a team what their RTO is, and most of them answer with how long the restore takes. Watch what that leaves out. &lt;strong&gt;The clock does not start when you start working. It starts when it breaks.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rto.py -- the clock starts when it breaks, not when you start working.
&lt;/span&gt;&lt;span class="n"&gt;detect&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# the alert fires, and a human actually sees it
&lt;/span&gt;&lt;span class="n"&gt;decide&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# somebody decides this is a disaster, not a blip
&lt;/span&gt;&lt;span class="n"&gt;provision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# new machines exist, and they are reachable
&lt;/span&gt;&lt;span class="n"&gt;restore&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;26&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# the data is put back onto them
&lt;/span&gt;&lt;span class="n"&gt;verify&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# somebody checks it is really the right data
&lt;/span&gt;&lt;span class="n"&gt;cut_over&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt;  &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# traffic is pointed at the new place
&lt;/span&gt;
&lt;span class="n"&gt;RTO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detect&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;decide&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;provision&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;restore&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cut_over&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RTO&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# 67  -&amp;gt; the real number, in minutes
&lt;/span&gt;
&lt;span class="c1"&gt;# the one term everybody budgets for, on its own:
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restore&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 26  -&amp;gt; not even half of the sixty seven
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty-seven minutes in total. And the one term everybody budgets for, the restore, is twenty-six of them. That is 38.8 percent. Not even half.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Minutes&lt;/th&gt;
&lt;th&gt;Can money make it shorter?&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Somebody actually sees the alert&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;no — people&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Somebody decides this is a real disaster&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;no — people&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;New machines exist and are reachable&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;partly&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The data is put back&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Somebody checks it is the right data&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;no — process&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Traffic is pointed at the new place&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;partly&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is why that split matters. The twenty-six is the part you can buy your way out of, with faster disks and a bigger pipe. The other &lt;strong&gt;forty-one minutes&lt;/strong&gt; are people and process. No hardware makes any of that shorter.&lt;/p&gt;

&lt;p&gt;So a team that spends its whole recovery budget on a faster restore &lt;strong&gt;cannot get its RTO below forty-one minutes&lt;/strong&gt;, no matter what else it spends. And it usually does not know that.&lt;/p&gt;

&lt;p&gt;If you want the number below forty-one, you have to stop restoring, and start already having a second copy running.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three tiers, and why you do not choose them
&lt;/h2&gt;

&lt;p&gt;Once both numbers are written down, something useful happens. The strategy stops being a choice.&lt;/p&gt;

&lt;p&gt;There are three tiers, and they sit at fixed points on those two numbers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;RTO&lt;/th&gt;
&lt;th&gt;RPO&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;A saved copy, and a rebuild&lt;/td&gt;
&lt;td&gt;hours&lt;/td&gt;
&lt;td&gt;however often you copy&lt;/td&gt;
&lt;td&gt;storage, and almost nothing else&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;A second site, kept warm&lt;/td&gt;
&lt;td&gt;minutes&lt;/td&gt;
&lt;td&gt;however far behind the copying runs&lt;/td&gt;
&lt;td&gt;a second bill, every month&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Two live sites, every write waits for both&lt;/td&gt;
&lt;td&gt;seconds&lt;/td&gt;
&lt;td&gt;near zero&lt;/td&gt;
&lt;td&gt;that second bill, plus something on every request&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cheaper and slower at the top. Faster and far more expensive at the bottom.&lt;/p&gt;

&lt;p&gt;For this shop, at fifty orders a minute, the difference between the rows is orders that were never placed at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Time down&lt;/th&gt;
&lt;th&gt;Orders never placed&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;A saved copy, and a rebuild&lt;/td&gt;
&lt;td&gt;67 minutes&lt;/td&gt;
&lt;td&gt;3,350&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;A second site, kept warm&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Two live sites&lt;/td&gt;
&lt;td&gt;20 seconds&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You do not shop for these three. You write down your two numbers, and exactly one row is left.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RTO is bought with: capacity that sits there
&lt;/h2&gt;

&lt;p&gt;Moving RTO down means having capacity that is &lt;strong&gt;already running&lt;/strong&gt; when the failure happens. Not capacity you can create quickly. Capacity that is already there.&lt;/p&gt;

&lt;p&gt;A second site kept warm is machines that are running, patched, watched and paid for on every ordinary day.&lt;/p&gt;

&lt;p&gt;Say the production environment costs four thousand two hundred euros a month. A full warm twin doubles that. &lt;strong&gt;Fifty thousand four hundred euros a year.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And here is the shape of that purchase. The bill arrives twelve times a year, every year. The outage it exists for might arrive once. Or never.&lt;/p&gt;

&lt;p&gt;That is not an argument against buying it. It is an argument for knowing exactly what you are buying.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RPO is bought with: waiting, on every write
&lt;/h2&gt;

&lt;p&gt;Now the other mark. This is where it gets interesting, because RPO is not bought with money. It is bought with waiting.&lt;/p&gt;

&lt;p&gt;Here is the same order, saved two ways.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# write.py -- the same order, saved two ways. Only the waiting differs.
&lt;/span&gt;&lt;span class="n"&gt;RTT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;               &lt;span class="c1"&gt;# a round trip to the second place, far away
&lt;/span&gt;&lt;span class="n"&gt;WRITES_PER_ORDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;      &lt;span class="c1"&gt;# one checkout touches six rows
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;      &lt;span class="c1"&gt;# RPO is the lag: about two seconds of orders
&lt;/span&gt;    &lt;span class="nf"&gt;write_here&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# the customer waits for this, and nothing else
&lt;/span&gt;    &lt;span class="nf"&gt;send_to_other_place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# this happens after the customer is gone
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;       &lt;span class="c1"&gt;# RPO is zero: nothing is ever lost
&lt;/span&gt;    &lt;span class="nf"&gt;write_here&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;wait_for_other_place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the customer waits for this too, every time
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RTT_MS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;WRITES_PER_ORDER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 72 -&amp;gt; milliseconds added to every order
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the first version, you write the order here, and send it to the other place afterwards. The customer waits for the first write and nothing else. RPO is whatever the sending falls behind by. Call it two seconds.&lt;/p&gt;

&lt;p&gt;In the second version, you write it here, and then you wait for the other place to say yes before you tell the customer their order went through. Now RPO is zero. Nothing is ever lost.&lt;/p&gt;

&lt;p&gt;But look at who is waiting. &lt;strong&gt;The customer is. Every time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the other place is far enough away that a round trip takes twelve milliseconds, and one checkout touches six rows, that is &lt;strong&gt;seventy-two milliseconds added to every order. Forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Seventy-two milliseconds does not sound like much. So multiply it out.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Orders a year, at 50 a minute&lt;/td&gt;
&lt;td&gt;26,280,000&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Added to each one&lt;/td&gt;
&lt;td&gt;72 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Added up, per year&lt;/td&gt;
&lt;td&gt;1,892,160 s = 525.6 hours = 21.9 days&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What it protects&lt;/td&gt;
&lt;td&gt;1.67 orders — EUR 63 — on the one bad day&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Almost twenty-two whole days of human waiting, added up, every single year. To buy back sixty-three euros of orders on a day that may or may not ever come.&lt;/p&gt;

&lt;p&gt;That is what driving RPO all the way to zero actually costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The step that is worth it, and the step right next to it
&lt;/h2&gt;

&lt;p&gt;Now put the money side by side. This shop loses one thousand nine hundred euros for every minute it is down — fifty orders a minute, thirty-eight euros an order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one. From sixty-seven minutes down to five.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That costs one more full environment: fifty thousand four hundred euros a year. It saves sixty-two minutes of downtime, which is &lt;strong&gt;one hundred and seventeen thousand, eight hundred euros per outage&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Break-even is &lt;strong&gt;0.43 outages a year&lt;/strong&gt;. Have one outage every two years, and it has already paid for itself. That is an easy yes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two. From five minutes down to twenty seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That costs another environment, and now every write waits for the far side. It saves four minutes and forty seconds, which is &lt;strong&gt;eight thousand, eight hundred and sixty-seven euros per outage&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Break-even is &lt;strong&gt;5.7 outages a year&lt;/strong&gt;. And you pay the five hundred and twenty-five hours of customer waiting either way.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Step one: 67 min → 5 min&lt;/th&gt;
&lt;th&gt;Step two: 5 min → 20 s&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Saved per outage&lt;/td&gt;
&lt;td&gt;EUR 117,800&lt;/td&gt;
&lt;td&gt;EUR 8,867&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Costs&lt;/td&gt;
&lt;td&gt;EUR 50,400 a year&lt;/td&gt;
&lt;td&gt;another EUR 50,400 a year&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Plus&lt;/td&gt;
&lt;td&gt;nothing on the request path&lt;/td&gt;
&lt;td&gt;525.6 hours of waiting a year&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Break-even&lt;/td&gt;
&lt;td&gt;0.43 outages a year&lt;/td&gt;
&lt;td&gt;5.7 outages a year&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Verdict&lt;/td&gt;
&lt;td&gt;an easy yes&lt;/td&gt;
&lt;td&gt;almost certainly no&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same ladder. Same shop. Same arithmetic. &lt;strong&gt;The step that is obviously worth it and the step that is obviously not are right next to each other.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the shape to remember. Both marks move a long way for money you can put in a budget. It is the last small step — pushing either mark all the way onto the failure itself — where the price stops being money and starts being waiting on every request you will ever serve.&lt;/p&gt;




&lt;h2&gt;
  
  
  How you actually pick
&lt;/h2&gt;

&lt;p&gt;You ask the business two questions, and you write the answers down before anybody says the word "backup".&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;How long may we be shut before it really hurts?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How much work may vanish before it really hurts?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those two answers are your RTO and your RPO. Everything after that is arithmetic, and the arithmetic is genuinely this small.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pick.py -- you do not pick a strategy. You pick two numbers.
# Ask the business first, and write both answers down before anything else.
&lt;/span&gt;&lt;span class="n"&gt;rto_minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;      &lt;span class="c1"&gt;# how long may we be shut before it really hurts?
&lt;/span&gt;&lt;span class="n"&gt;rpo_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;      &lt;span class="c1"&gt;# how much work may vanish before it really hurts?
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rto_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rto_minutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a saved copy, and a rebuild&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="c1"&gt;# cheapest. Hours.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rto_minutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a second site, kept warm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;           &lt;span class="c1"&gt;# minutes
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;two live sites, and every write waits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# seconds, and it hurts
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rto_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rpo_seconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# a second site, kept warm
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two numbers came first. The tier came out of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three questions you can answer this afternoon
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First.&lt;/strong&gt; If the main copy of your data disappeared right now, how many minutes of work would be gone? Not on average. In the worst case. If nobody can say the number, you do not have an RPO — you have a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second.&lt;/strong&gt; Add up your six terms: seeing the alert, deciding it is real, getting machines, putting the data back, checking it, switching traffic over. What is your floor once you remove the restore? If that floor is already above what you promised, buying a faster restore cannot save you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third.&lt;/strong&gt; Who agreed to these two numbers? If the answer is "the engineers", they are estimates. They become objectives when the business says them out loud.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;RTO&lt;/th&gt;
&lt;th&gt;RPO&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;The question&lt;/td&gt;
&lt;td&gt;How long may we be down?&lt;/td&gt;
&lt;td&gt;How much work may vanish?&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Measured&lt;/td&gt;
&lt;td&gt;forwards from the break&lt;/td&gt;
&lt;td&gt;backwards from the break&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Set by&lt;/td&gt;
&lt;td&gt;a sum of six terms, only one of which is the restore&lt;/td&gt;
&lt;td&gt;how often you copy — nothing else&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Bought with&lt;/td&gt;
&lt;td&gt;capacity running on every ordinary day&lt;/td&gt;
&lt;td&gt;a wait added to every write, forever&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;You pay&lt;/td&gt;
&lt;td&gt;whether or not anything ever breaks&lt;/td&gt;
&lt;td&gt;on every request you will ever serve&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Fails quietly when&lt;/td&gt;
&lt;td&gt;the budget all goes to a faster restore&lt;/td&gt;
&lt;td&gt;the schedule is quoted as an average&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;RTO is how long you may be down&lt;/strong&gt;, and you buy it with machines that sit there costing money while nothing is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RPO is how much work may vanish&lt;/strong&gt;, and you buy it with a wait added to every write, forever.&lt;/p&gt;

&lt;p&gt;Write both numbers down before anybody says the words "backup", "standby" or "second region". After that, the tier is the only thing left to read off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You do not pick a disaster recovery strategy. You pick two numbers, and the strategy falls out of them.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the two definitions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NIST Special Publication 800-34 Rev. 1, &lt;em&gt;Contingency Planning Guide for Federal Information Systems&lt;/em&gt; (NIST, 2010) — the formal definitions both terms come from: RTO as the maximum time a resource may be unavailable before the impact is unacceptable, and RPO as the point in time to which data can be recovered, which in practice is how much data loss the organisation can tolerate: &lt;a href="https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-34r1.pdf" rel="noopener noreferrer"&gt;nvlpubs.nist.gov&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On writing the two numbers down with the business&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Laine Campbell and Charity Majors, &lt;em&gt;Database Reliability Engineering&lt;/em&gt; (O'Reilly, 2017), ch. 2 "Service-Level Management" and ch. 7 "Backup and Recovery" — the same two numbers, under the names availability and durability, treated as service-level indicators agreed in advance. Durability is defined there in exactly this shape: "in the event of a system failure, no more than the past two seconds of data can be lost." Chapter 7 then opens the recovery-strategy discussion by sending you back to those objectives first.&lt;/li&gt;
&lt;li&gt;Betsy Beyer, Niall Richard Murphy, David K. Rensin, Kent Kawahara and Stephen Thorne (eds.), &lt;em&gt;The Site Reliability Workbook&lt;/em&gt; (O'Reilly, 2018), ch. 2 "Implementing SLOs" — how a reliability target is chosen with the people who own the product rather than by the team that will engineer to it, and why the rationale behind the number gets written down with it: &lt;a href="https://sre.google/workbook/implementing-slos/" rel="noopener noreferrer"&gt;sre.google/workbook/implementing-slos&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the restore being the small term&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer, Chris Jones, Jennifer Petoff and Niall Richard Murphy (eds.), &lt;em&gt;Site Reliability Engineering&lt;/em&gt; (O'Reilly, 2016), ch. 26 "Data Integrity: What You Read Is What You Wrote" — "No one really &lt;em&gt;wants&lt;/em&gt; to make backups; what people &lt;em&gt;really&lt;/em&gt; want are &lt;em&gt;restores&lt;/em&gt;", and the requirement that recovery finish "well within the uptime needs of a service", which is the RTO by another name: &lt;a href="https://sre.google/sre-book/data-integrity/" rel="noopener noreferrer"&gt;sre.google/sre-book/data-integrity&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On what the last step of RPO costs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017), ch. 5 "Replication", section "Synchronous Versus Asynchronous Replication" — waiting for the second copy to acknowledge is what removes the loss window, and it is also what puts an unbounded wait on the write path, which is why almost all replication is asynchronous in practice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the last rung costing more than the outage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer et al. (eds.), &lt;em&gt;Site Reliability Engineering&lt;/em&gt; (O'Reilly, 2016), ch. 3 "Embracing Risk" — reliability is not maximised, it is targeted: "an incremental improvement in reliability may cost 100x more than the previous increment", and the target is treated as both a minimum and a maximum: &lt;a href="https://sre.google/sre-book/embracing-risk/" rel="noopener noreferrer"&gt;sre.google/sre-book/embracing-risk&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/koRSzvlIX1Q" rel="noopener noreferrer"&gt;RTO vs RPO on YouTube&lt;/a&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cache Invalidation: The Delete Ran, and the Page Is Still Wrong</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:56:48 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/cache-invalidation-the-delete-ran-and-the-page-is-still-wrong-55g2</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/cache-invalidation-the-delete-ran-and-the-page-is-still-wrong-55g2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/r2kGBLfmyv0" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/cache-invalidation-the-delete-ran-and-the-page-is-still-wrong?id=184" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The ticket that makes no sense
&lt;/h2&gt;

&lt;p&gt;A user changes her email address. The form says &lt;strong&gt;saved&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;She reloads the page. The old address is still there.&lt;/p&gt;

&lt;p&gt;You go and look, because you wrote this code and you know it is fine. The database has the new value. You check the logs: the &lt;code&gt;UPDATE&lt;/code&gt; returned ok. The cache delete right after it returned ok too. There is no exception, no timeout, no retry, no failed job sitting in a queue. Every single call in that request succeeded.&lt;/p&gt;

&lt;p&gt;And the cache is still holding the old email, with &lt;strong&gt;five minutes&lt;/strong&gt; left on its timer.&lt;/p&gt;

&lt;p&gt;Nothing failed. Nobody forgot anything. So what actually happened?&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three things are covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why an invalidation that &lt;strong&gt;provably ran&lt;/strong&gt; can leave a stale value behind — and why the delete was not too late, it was too &lt;strong&gt;early&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The three moves that fix it, in order, each one measured rather than asserted.&lt;/li&gt;
&lt;li&gt;The honest limit: the class of cached answers you cannot invalidate at all, and what the workaround costs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five things are deliberately not covered, because each one is its own subject and gets confused with this one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is not the dual-write problem.&lt;/strong&gt; That is the case where the second write never happened — the database committed and the cache call died. Here the cache call ran and returned successfully. If your failure mode is a lost second write, you want an outbox, not this article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not the write path.&lt;/strong&gt; &lt;em&gt;Write-through&lt;/em&gt; and &lt;em&gt;write-back&lt;/em&gt; are about whether writes go through the cache at all. Everything below assumes &lt;strong&gt;cache-aside&lt;/strong&gt;: your application code reads the cache, falls back to the database, and fills the cache itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not the stampede.&lt;/strong&gt; Invalidating a hot key sends every waiting reader to the database at the same instant. That is a real and separate cost, and it is a real and separate article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not eviction.&lt;/strong&gt; LRU and LFU decide what to throw away when memory runs out. Eviction is about &lt;strong&gt;space&lt;/strong&gt;. Invalidation is about &lt;strong&gt;truth&lt;/strong&gt;. They both remove keys and that is the entire similarity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not HTTP caching.&lt;/strong&gt; No browser caches, no &lt;code&gt;ETag&lt;/code&gt;, no &lt;code&gt;max-age&lt;/code&gt;, no CDN purge. Everything here happens on your side of the wire, inside your own service.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  One system, drawn once
&lt;/h2&gt;

&lt;p&gt;There are exactly three things, and everything below is a mutation of this picture.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;service&lt;/strong&gt;. Beside it a &lt;strong&gt;cache&lt;/strong&gt; — say one Redis. Under it a &lt;strong&gt;database&lt;/strong&gt; — say one Postgres primary. The service talks to both. The cache and the database &lt;strong&gt;never talk to each other&lt;/strong&gt;. Nothing keeps them in step except the code you write.&lt;/p&gt;

&lt;p&gt;The read path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# every read goes through here. ex=300 is five minutes.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;          &lt;span class="c1"&gt;# a hit. No database call.
&lt;/span&gt;    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# a miss. Fill it back.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the write path — the one in the ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the write path. Two calls, and both of them succeed.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE users SET email = %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Order matters. Database first, cache second.
# Delete before the commit and a reader can refill the
# key from the old row before the new one is even there.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at those two functions. They are correct. That is the point of the article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The race: the delete was too early
&lt;/h2&gt;

&lt;p&gt;Put a second person in the picture. Not a second writer — a &lt;strong&gt;reader&lt;/strong&gt;, doing nothing unusual.&lt;/p&gt;

&lt;p&gt;Follow one clock:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A read arrives. Cache &lt;strong&gt;miss&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;That reader runs &lt;code&gt;SELECT&lt;/code&gt; and gets the &lt;strong&gt;old&lt;/strong&gt; row. It is now holding that value in a local variable, on its way to &lt;code&gt;cache.set&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The writer commits &lt;code&gt;UPDATE&lt;/code&gt;. The database now holds the &lt;strong&gt;new&lt;/strong&gt; value.&lt;/li&gt;
&lt;li&gt;The writer runs &lt;code&gt;DELETE user:42&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The reader — a couple of milliseconds late — finally runs &lt;code&gt;cache.set(key, old_row, ex=300)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The database says new. The cache says old. And the cache has a &lt;strong&gt;full five-minute timer&lt;/strong&gt; ahead of it, because step 5 set a fresh one.&lt;/p&gt;

&lt;p&gt;Here is the part that surprises people, measured on a real Redis 7.4.9 rather than reasoned about. In step 4, the delete returns:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;4. writer DEL user:42 -&amp;gt; DEL returned 0
   (key existed before = 0, exists after = 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Zero.&lt;/strong&gt; The delete removed nothing, and it removed nothing because &lt;em&gt;there was nothing there yet&lt;/em&gt;. The stale value was still in flight inside another thread. You cannot delete a value that has not been written.&lt;/p&gt;

&lt;p&gt;Say that out loud, because it is the whole idea: &lt;strong&gt;the invalidation was not too late. It was too early.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And it is not a fluke of the cold cache. A second run was built where a different reader had already cached the old value, so the delete had a real victim and returned &lt;code&gt;1&lt;/code&gt;. Same ending: database new, cache old, five minutes on the clock. A delete that provably removed a real key is still undone by one slow reader.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often, honestly
&lt;/h3&gt;

&lt;p&gt;The tempting thing to do here is put a percentage on the screen. It would be wrong, because there is no single rate. The rate is a ratio:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;how long a reader takes between reading the row and writing it to the cache, over how often writes arrive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Timing every step of 5,000 runs, that predicate predicted the actual outcome &lt;strong&gt;99.8–100%&lt;/strong&gt; of the time — the mechanism is verified, not assumed. But the rate moved enormously with database commit latency:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Reader gap (row read → cache set)&lt;/th&gt;
&lt;th&gt;With a ~3.3 ms fsync commit&lt;/th&gt;
&lt;th&gt;With a ~0.02 ms commit&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;0 / 500 (0.00%)&lt;/td&gt;
&lt;td&gt;1 / 500 (0.20%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;0 / 500 (0.00%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56 / 500 (11.20%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;td&gt;31 / 500 (6.20%)&lt;/td&gt;
&lt;td&gt;122 / 500 (24.40%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;10 ms&lt;/td&gt;
&lt;td&gt;148 / 500 (29.60%)&lt;/td&gt;
&lt;td&gt;264 / 500 (52.80%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;20 ms&lt;/td&gt;
&lt;td&gt;400 / 500 (80.00%)&lt;/td&gt;
&lt;td&gt;416 / 500 (83.20%)&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Below a 3 ms gap with slow commits it is literally &lt;code&gt;0/500&lt;/code&gt;, because the writer physically cannot finish before the reader has already filled the cache. Make your database faster and the window opens. This is not a bug you can benchmark your way out of.&lt;/p&gt;

&lt;h3&gt;
  
  
  The case that actually hurts
&lt;/h3&gt;

&lt;p&gt;Under continuous writes to the same row this heals itself — the next writer's delete clears the poisoned key. Measured under eight readers and one writer hammering the same row, the cache held a wrong value at &lt;strong&gt;0.43%&lt;/strong&gt; of sampled instants, and &lt;strong&gt;0.48%&lt;/strong&gt; of hits were wrong: about 68 wrong answers in six seconds, in short bursts.&lt;/p&gt;

&lt;p&gt;The dangerous case is the opposite one. &lt;strong&gt;A row nobody writes again.&lt;/strong&gt; A profile edited once. A price changed once. A feature flag flipped once. There is no next writer to heal it, so the wrong value sits there for the entire remaining TTL, answering every read.&lt;/p&gt;

&lt;p&gt;That is the common case, and it is the expensive one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move 1: delete, do not update
&lt;/h2&gt;

&lt;p&gt;The first instinct after seeing this is to make the write path &lt;em&gt;more&lt;/em&gt; authoritative: I already know the new value, so let me put it straight into the cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tempting, and wrong: it writes a value you computed
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_email_bad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_and_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# what you actually want: it writes no value at all
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change_email_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UPDATE users SET email = %s WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the first one is worse: two writers can commit in the order A then B, and have their &lt;strong&gt;cache&lt;/strong&gt; writes arrive in the order B then A. The database ends holding B, which is right. The cache ends holding A — a value that nobody wrote last — and it holds it for a full TTL.&lt;/p&gt;

&lt;p&gt;Two concurrent writers, 1,500 runs each way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;Cache order ≠ commit order&lt;/th&gt;
&lt;th&gt;Cache ≠ database at the end&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;write, then &lt;code&gt;SET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;590 / 1500 (39.33%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;589 / 1500 (39.27%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;write, then &lt;code&gt;DELETE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;573 / 1500 (38.20%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 1500 (0.00%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reorder happens just as often either way — 39% versus 38%, that is the same physics. Under &lt;code&gt;SET&lt;/code&gt;, essentially &lt;strong&gt;every&lt;/strong&gt; reorder produced a wrong cache. Under &lt;code&gt;DELETE&lt;/code&gt;, &lt;strong&gt;none&lt;/strong&gt; did, because a delete carries no value to get out of order. It says &lt;em&gt;"whatever is there is now suspect"&lt;/em&gt;, and the next reader re-reads the truth.&lt;/p&gt;

&lt;p&gt;Two honest qualifications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs you a miss.&lt;/strong&gt; That miss is priced:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Hit&lt;/th&gt;
&lt;th&gt;Miss (get + select + set)&lt;/th&gt;
&lt;th&gt;Extra cost of the miss&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Local database, no network&lt;/td&gt;
&lt;td&gt;91.9 µs&lt;/td&gt;
&lt;td&gt;176.4 µs&lt;/td&gt;
&lt;td&gt;+84.6 µs&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;With a 5 ms database round trip&lt;/td&gt;
&lt;td&gt;77.5 µs&lt;/td&gt;
&lt;td&gt;6.91 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+6.84 ms, once&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven milliseconds, one time. Compare that against what the &lt;code&gt;SET&lt;/code&gt; policy buys you in the next section: five figures of wrong answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it does not fix everything.&lt;/strong&gt; &lt;code&gt;DELETE&lt;/code&gt; removes the writer-versus-writer reorder completely. It does &lt;strong&gt;not&lt;/strong&gt; remove the read-path race from the section above — that one is still there, and it is what versioned keys and a sane TTL are for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move 2: put a version in the name
&lt;/h2&gt;

&lt;p&gt;The second problem is not correctness, it is bookkeeping. One user does not have one cached key. She has a profile, her settings, five pages of feed, her friends list, her badges, her unread count, her avatar. Eleven keys, in this example.&lt;/p&gt;

&lt;p&gt;When her row changes, which of those do you delete?&lt;/p&gt;

&lt;p&gt;You could hunt. Measured on a 200,000-key cache:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;How you find them&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;th&gt;Round trips&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;All 11 in one variadic &lt;code&gt;DEL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.16 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;you must know all 11 names&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;11 named &lt;code&gt;DEL&lt;/code&gt;s, one at a time&lt;/td&gt;
&lt;td&gt;1.93 ms&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;you must know all 11 names&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEYS user:42:*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12.4 ms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;blocks the whole server&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;SCAN MATCH user:42:* COUNT=1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;58.0 ms&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;safe, but 200 trips&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEYS *&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;299.7 ms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;blocks the whole server&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason &lt;code&gt;KEYS&lt;/code&gt; is disqualified is not its own runtime. Redis executes commands on a single thread, so while &lt;code&gt;KEYS *&lt;/code&gt; runs, &lt;em&gt;everyone else waits&lt;/em&gt;. Latency of an unrelated &lt;code&gt;GET&lt;/code&gt; from a second client, measured during it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Server state&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;max&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;idle&lt;/td&gt;
&lt;td&gt;0.079 ms&lt;/td&gt;
&lt;td&gt;0.177 ms&lt;/td&gt;
&lt;td&gt;0.279 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;while &lt;code&gt;KEYS *&lt;/code&gt; runs&lt;/td&gt;
&lt;td&gt;0.721 ms&lt;/td&gt;
&lt;td&gt;246.9 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;516.2 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An unrelated &lt;code&gt;GET&lt;/code&gt; got &lt;strong&gt;1,851× worse at the tail&lt;/strong&gt;. &lt;code&gt;SCAN&lt;/code&gt; exists precisely so you never do that.&lt;/p&gt;

&lt;p&gt;But look again at the top row of the hunt table. &lt;strong&gt;If you genuinely know all eleven names, one variadic &lt;code&gt;DEL&lt;/code&gt; does it in 0.16 ms&lt;/strong&gt; — faster than any alternative here. So the cost of the hunt was never round trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost of the hunt is knowing the names.&lt;/strong&gt; They are scattered across a codebase, built by string concatenation in six different modules, and the bug is always the twelfth key somebody added last month and forgot to add to the list. The list &lt;em&gt;is&lt;/em&gt; the bug.&lt;/p&gt;

&lt;p&gt;So stop keeping a list. Put a version in the name instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the key carries a version number for that one user
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;key_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:ver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:%s:v%s:%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# invalidating every derived key for one user
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invalidate_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:ver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# One call. Every old key is now unreachable, because
# nothing composes those names any more. You never had
# to know how many there were, or what they were called.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;INCR&lt;/code&gt;, 0.701 ms, one round trip. Keys a &lt;code&gt;v8&lt;/code&gt; reader can still reach: &lt;strong&gt;0 of 11&lt;/strong&gt;. Not because they were deleted — because nothing constructs those names any more.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest cost: orphans
&lt;/h3&gt;

&lt;p&gt;Nothing about this is free, and the bill is memory.&lt;/p&gt;

&lt;p&gt;After the bump, all &lt;strong&gt;11 of 11&lt;/strong&gt; old keys are still resident, with &lt;strong&gt;3,600 seconds&lt;/strong&gt; of TTL left on them. Nothing reclaims them early. They held 16,552 bytes for one user — about 1,505 bytes per key. Scale that: &lt;strong&gt;100,000 users bumping once is roughly 1.7 GB&lt;/strong&gt; of unreachable data sitting in your cache until it expires.&lt;/p&gt;

&lt;p&gt;That is only survivable under one of two conditions: &lt;strong&gt;every versioned key has a TTL&lt;/strong&gt;, or your &lt;code&gt;maxmemory-policy&lt;/code&gt; is an LRU/LFU variant that will evict them. Redis defaults to &lt;code&gt;noeviction&lt;/code&gt;, which means an orphan pile eventually stops accepting writes rather than making room.&lt;/p&gt;

&lt;p&gt;Versioning trades &lt;strong&gt;memory you cannot reach&lt;/strong&gt; for &lt;strong&gt;invalidation you cannot get wrong&lt;/strong&gt;. That is usually a good trade. It is not a free one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move 3: the timer is a backstop, not a plan
&lt;/h2&gt;

&lt;p&gt;Every cache line you write has an &lt;code&gt;ex=&lt;/code&gt;, and it is the most misunderstood argument in the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the TTL is not the plan. It is the bound.
&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# what that one argument actually promises:
#   it does NOT make the copy correct
#   it DOES cap how long a wrong copy can live
#   so pick it from what a stale answer costs you,
#   not from how much memory you happen to have
&lt;/span&gt;
&lt;span class="c1"&gt;# and this line says: be wrong forever
&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A TTL has never once made a cached value correct. All it does is put a ceiling on how long a wrong one is allowed to keep answering. So price it that way. One incident, one poisoned key, fifty reads a second:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;Wrong answers served&lt;/th&gt;
&lt;th&gt;Self-heals?&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;5 s&lt;/td&gt;
&lt;td&gt;251 (measured)&lt;/td&gt;
&lt;td&gt;yes, after 5.02 s&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;10 s&lt;/td&gt;
&lt;td&gt;500 (measured)&lt;/td&gt;
&lt;td&gt;yes, after 10.00 s&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;1,500 (measured)&lt;/td&gt;
&lt;td&gt;yes, after 30.00 s&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;~15,000 (extrapolated)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;~180,000 (extrapolated)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;unbounded&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The measured rate came out at &lt;strong&gt;50.07 wrong answers per second of TTL&lt;/strong&gt; at fifty reads a second — linear enough that the extrapolations are safe. And the rate is what makes the number mean anything: "fifteen thousand" is only true &lt;em&gt;at fifty reads a second&lt;/em&gt;, on a row nobody writes again.&lt;/p&gt;

&lt;p&gt;Which reframes the question you should be asking. Not "how long can I cache this for?" but &lt;strong&gt;"how many wrong answers is this row worth?"&lt;/strong&gt; A stale marketing headline: thousands, fine. A stale account balance, permission check, or price: the honest answer might be zero, and that is a signal not to cache that read at all.&lt;/p&gt;

&lt;p&gt;The last line in that snippet — &lt;code&gt;cache.set(key, row)&lt;/code&gt; with no expiry — is a promise to be wrong forever if anything ever goes sideways. It is worth grepping for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The limit: you cannot invalidate what you cannot name
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the cached value has a name derived from the thing that changed. &lt;code&gt;user:42:profile&lt;/code&gt; obviously belongs to user 42.&lt;/p&gt;

&lt;p&gt;Now cache a search result. The key is a hash of the query — &lt;code&gt;search:results:&amp;lt;sha1&amp;gt;&lt;/code&gt; — and the value happens to contain twenty user records. Or cache &lt;code&gt;leaderboard:top10&lt;/code&gt;, computed from every score in the system.&lt;/p&gt;

&lt;p&gt;User 42 changes her name. &lt;strong&gt;Which keys do you delete?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no answer. The key name is a function of the &lt;em&gt;query&lt;/em&gt;, not of user 42. &lt;code&gt;leaderboard:top10&lt;/code&gt; does not mention a user at all. Every classic move above is unavailable, because all of them start with a name.&lt;/p&gt;

&lt;p&gt;The only way back is to write the mapping down as you fill the cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# when you cache a COMPUTED answer, write down which
# rows it was built from. That list is the only way
# back from a changed row to the keys holding it.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cache_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sadd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag:user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;query_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# when a row changes, read its list and delete them
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invalidate_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag:user:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;smembers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over 5,000 cached search pages, finding the 40 keys that embed user 42:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Found&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Round trips&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Brute force: scan, fetch and parse all 5,000 values&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;40.8 ms&lt;/td&gt;
&lt;td&gt;10 scans + 5,000 values parsed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Tag set: &lt;code&gt;SMEMBERS tag:user:42&lt;/code&gt; then delete&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.860 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;47× faster&lt;/strong&gt;, and the gap widens as the keyspace grows, because the tag lookup does not care how many keys exist.&lt;/p&gt;

&lt;p&gt;And here is what it costs, which is the part usually left out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;20× write amplification.&lt;/strong&gt; Building the index for those 5,000 pages took 100,000 &lt;code&gt;SADD&lt;/code&gt; calls — one per embedded entity, on every single cache fill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;+130% memory.&lt;/strong&gt; 2.58 MB of cached pages became 5.93 MB. The reverse index more than doubled the workload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dangling members forever.&lt;/strong&gt; After invalidating user 42, the neighbouring &lt;code&gt;tag:user:43&lt;/code&gt; set still listed two keys that no longer exist. Nothing cleans them. Tag sets grow without bound unless you also keep a &lt;code&gt;key → tags&lt;/code&gt; reverse map (doubling the write amplification again) or put a TTL on the tag sets themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tags turn an impossible problem into a &lt;strong&gt;solvable but expensive&lt;/strong&gt; one, and they introduce a second garbage-collection problem of their own. That is the trade. Make it deliberately, on the handful of computed keys that actually need it — not as a default for everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same problem, wearing an AI hat
&lt;/h2&gt;

&lt;p&gt;If you are serving an LLM application, you already have three caches and probably have not named them as caches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The semantic cache is the unnameable-key problem, exactly.&lt;/strong&gt; You store an answer under an embedding of the question so that a similar question can reuse it. Now a document changes, or a price changes, or a policy changes. Which cached answers were built from that fact? The key is a vector derived from the &lt;em&gt;question&lt;/em&gt; — it says nothing about which sources produced the answer. There is no name to delete. This is the search-result case from the section above, and the way out is the same: record which retrieved chunks fed each cached answer, tag by source document, and accept the write amplification. If you cannot afford that bookkeeping, the honest fallback is a short TTL, priced by the table above — &lt;em&gt;how many wrong answers is this document worth?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The RAG index is derived data with a version.&lt;/strong&gt; Re-embedding after a chunker change, an embedding-model swap, or a bulk document update is the version bump from move 2 — a new index name, atomically switched, rather than a hunt for which vectors are stale. The orphan bill is the same too: the old index stays resident and costs money until something deletes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the write-then-delete rule survives intact.&lt;/strong&gt; Two pipelines that re-embed the same document concurrently can have their index writes land in the opposite order to their source commits, leaving the index holding a vector nobody wrote last. Invalidating the entry and letting the next read re-embed removes that class of bug for the price of one miss — and in this setting the miss is a real embedding call, so it is worth measuring rather than assuming.&lt;/p&gt;

&lt;p&gt;The provider-side prompt cache is the one you do not control: it has its own timer and its own key derivation, so treat it as a bound you were given, not a plan you made.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The delete ran. That was never the problem.&lt;/p&gt;

&lt;p&gt;Invalidation fails because of &lt;strong&gt;ordering&lt;/strong&gt;, not omission: a reader that read before the write and wrote after it, holding a stale value in a variable across the exact moment the delete fired. The delete was too early, not too late.&lt;/p&gt;

&lt;p&gt;So, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete, never update.&lt;/strong&gt; 589 divergences in 1,500 runs the other way, zero this way. It costs one miss — about 7 ms once, against a five-figure count of wrong answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a version in the name&lt;/strong&gt; when a row has many derived keys. Not because it is faster — a variadic &lt;code&gt;DEL&lt;/code&gt; beats it — but because you never needed the list of names, and the list was always the bug. Budget for the orphans, and give every versioned key a TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the TTL as a bound&lt;/strong&gt;, and choose it from what a wrong answer costs rather than from how much memory you have. A cache line with no &lt;code&gt;ex=&lt;/code&gt; is a promise to be wrong forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And then the limit: &lt;strong&gt;you cannot invalidate what you cannot name.&lt;/strong&gt; For computed answers — search results, leaderboards, semantically cached LLM responses — either write down the mapping and pay for it, or admit you are relying on a timer and price that timer honestly.&lt;/p&gt;

&lt;p&gt;The two-hard-things joke gets repeated because it is funny. The real reason invalidation is hard is duller and more useful: a cache is a second copy of the truth, kept in step by nothing but your own code, on a clock that two independent parties are writing to at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the pattern itself&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft, &lt;em&gt;Cache-Aside pattern&lt;/em&gt;, Azure Architecture Center — the pattern every code sample above assumes: the application, not the cache, is responsible for loading data on a miss and for removing it when the underlying data changes, with the consistency caveats stated plainly: &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside" rel="noopener noreferrer"&gt;learn.microsoft.com/azure/architecture/patterns/cache-aside&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On deleting rather than updating, and on the read-path race&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rajesh Nishtala et al., &lt;em&gt;Scaling Memcache at Facebook&lt;/em&gt; (USENIX NSDI, 2013) — the production argument for deleting cached data instead of updating it (deletes are idempotent, so concurrent invalidations cannot reorder into a wrong value), and the "stale set" race in which a reader holding an old value writes it back after the invalidation — the exact failure this article opens on, which is why the paper introduces leases to close it: &lt;a href="https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf" rel="noopener noreferrer"&gt;usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On finding the keys, and why &lt;code&gt;KEYS&lt;/code&gt; is disqualified&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis, &lt;em&gt;&lt;code&gt;SCAN&lt;/code&gt; command reference&lt;/em&gt; — the cursor-based iteration guarantees, and the explicit warning that &lt;code&gt;KEYS&lt;/code&gt; may block the server for a long time on a large keyspace, which is what the tail-latency measurement above shows in practice: &lt;a href="https://redis.io/docs/latest/commands/scan/" rel="noopener noreferrer"&gt;redis.io/docs/latest/commands/scan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On what a TTL actually promises&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis, &lt;em&gt;&lt;code&gt;EXPIRE&lt;/code&gt; command reference and key expiration semantics&lt;/em&gt; — how expiry is stored with the key, how it is cleared by a write that replaces the value, and the lazy plus active expiration model that decides when the key really goes away: &lt;a href="https://redis.io/docs/latest/commands/expire/" rel="noopener noreferrer"&gt;redis.io/docs/latest/commands/expire&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the orphans left behind by versioned keys&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redis, &lt;em&gt;Key eviction and &lt;code&gt;maxmemory-policy&lt;/code&gt;&lt;/em&gt; — the available policies and the fact that the default is &lt;code&gt;noeviction&lt;/code&gt;, which is why unreachable versioned keys are only reclaimed by their own TTL and can otherwise fill the instance until writes start failing: &lt;a href="https://redis.io/docs/latest/develop/reference/eviction/" rel="noopener noreferrer"&gt;redis.io/docs/latest/develop/reference/eviction&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On caches as derived data — the "cannot name it" limit&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Kleppmann, &lt;em&gt;Designing Data-Intensive Applications&lt;/em&gt; (O'Reilly, 2017), ch. 11 "Stream Processing", sections on change data capture and derived data — a cache is a derived view of a system of record, and keeping derived views correct by ad-hoc writes scattered through application code is precisely what produces the inconsistencies above; a single ordered stream of changes is the alternative framing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/r2kGBLfmyv0" rel="noopener noreferrer"&gt;Cache Invalidation — Explained in Detail&lt;/a&gt; · &lt;a href="https://youtube.com/shorts/g98sZl_esGE" rel="noopener noreferrer"&gt;the short version&lt;/a&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>redis</category>
    </item>
    <item>
      <title>Continuous Integration vs Continuous Delivery: The Two Halves Almost Nobody Separates</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 29 Aug 2026 19:56:36 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/continuous-integration-vs-continuous-delivery-the-two-halves-almost-nobody-separates-13jf</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/continuous-integration-vs-continuous-delivery-the-two-halves-almost-nobody-separates-13jf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/J0JcRX0jokM" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/continuous-integration-vs-continuous-delivery-the-two-halves-almost-nobody-separates?id=183" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Almost everybody writes these two words as one word. CI/CD. It gets said like the name of a product you install.&lt;/p&gt;

&lt;p&gt;It is not one thing. It is two rules. They are about two different objects, and they point in opposite directions. Most teams follow the first one and stop there, and they do not notice they stopped, because the two words have been glued together for so long.&lt;/p&gt;

&lt;p&gt;This article takes them apart.&lt;/p&gt;




&lt;h2&gt;
  
  
  Merge day
&lt;/h2&gt;

&lt;p&gt;Start with the thing that made you look this up.&lt;/p&gt;

&lt;p&gt;Six developers. One codebase. Six branches open at the same time. A &lt;strong&gt;branch&lt;/strong&gt; is your own private copy of the code, where you work without disturbing anyone else. Yours opened on a Monday. You are building one feature, and it touches eighteen files.&lt;/p&gt;

&lt;p&gt;While you work, the other five keep working too. Each of them lands one change on the shared &lt;strong&gt;trunk&lt;/strong&gt; — the one main copy of the code that everybody shares — every day. You do not see any of it.&lt;/p&gt;

&lt;p&gt;After one day, five changes have landed underneath you. That is fine.&lt;/p&gt;

&lt;p&gt;After six weeks, one hundred and fifty changes have landed underneath you. Your branch has not moved. The ground it was standing on has.&lt;/p&gt;

&lt;p&gt;Then comes merge day. Nobody put merge day in the plan. Nobody estimated it. But it is a week of work, it happens every single time, and the team has quietly decided that this is simply what software feels like.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this article covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;Three things are covered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What continuous integration actually is, and why it is a rule about &lt;strong&gt;people&lt;/strong&gt; rather than a piece of software.&lt;/li&gt;
&lt;li&gt;What continuous delivery actually is, and why it is a rule about &lt;strong&gt;one object&lt;/strong&gt;, called the artifact.&lt;/li&gt;
&lt;li&gt;The third word, &lt;strong&gt;deployment&lt;/strong&gt;, which is not the same as delivery, and which is where most of the arguments come from.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three things are not covered. &lt;strong&gt;No tool is named anywhere in this article.&lt;/strong&gt; Not one. How a release is rolled out to users is a different subject, covered elsewhere on this site. And this is not advice about how to write tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  The workshop
&lt;/h2&gt;

&lt;p&gt;The nearest confusion is this one. People think CI/CD is a single thing. A file in the repository. A list of steps that a machine runs. So they install it, and then they say they have it.&lt;/p&gt;

&lt;p&gt;Picture a workshop instead.&lt;/p&gt;

&lt;p&gt;On the left there is one bench, with one wooden frame being built on it. Everybody who makes a piece brings it to that bench &lt;strong&gt;today&lt;/strong&gt;, and fits it into the frame &lt;strong&gt;today&lt;/strong&gt;. If two pieces do not fit each other, you find that out today, while each piece is still one cut away from fitting.&lt;/p&gt;

&lt;p&gt;On the right there is a loading dock. There is one crate. It is packed, sealed, stamped, and standing at the door. A lorry is waiting outside. Whether the crate leaves is a separate decision, made by a person.&lt;/p&gt;

&lt;p&gt;The bench is continuous integration. The crate is continuous delivery. Two different rules, about two different objects.&lt;/p&gt;




&lt;h2&gt;
  
  
  One instant, two halves
&lt;/h2&gt;

&lt;p&gt;Here is the whole article in one picture.&lt;/p&gt;

&lt;p&gt;Draw the life of one change as a line of time. Mark one instant on that line. That instant is the &lt;strong&gt;green build&lt;/strong&gt;: the moment your change has joined everybody else's work, the machine has built it, the tests have run, and everything passed.&lt;/p&gt;

&lt;p&gt;Everything to the &lt;strong&gt;left&lt;/strong&gt; of that instant is continuous integration. It looks backwards, at the merge. Its question is: &lt;em&gt;how old is the oldest piece of work that has not joined the trunk yet?&lt;/em&gt; A good answer is measured in hours.&lt;/p&gt;

&lt;p&gt;Everything to the &lt;strong&gt;right&lt;/strong&gt; of that instant is continuous delivery. It looks forwards, at the artifact. Its question is: &lt;em&gt;how much stands between this build and a real user?&lt;/em&gt; A good answer is one button.&lt;/p&gt;

&lt;p&gt;Two halves. One instant between them. Pointing in opposite directions. Every section below is this same picture with the marks moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a long branch actually costs
&lt;/h2&gt;

&lt;p&gt;The cost of a long branch is not the cost most people assume. It is worth making concrete, because the arithmetic says something surprising.&lt;/p&gt;

&lt;p&gt;Here is a small model of the team.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# drift.py -- what a branch costs is not its size. It is its age.
&lt;/span&gt;&lt;span class="n"&gt;TEAM&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;      &lt;span class="c1"&gt;# developers on this one codebase
&lt;/span&gt;&lt;span class="n"&gt;REPO_FILES&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;   &lt;span class="c1"&gt;# files that anybody might touch
&lt;/span&gt;&lt;span class="n"&gt;FILES_I_TOUCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;     &lt;span class="c1"&gt;# what my one branch actually edits
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# every OTHER developer lands one change a day while my branch sits still
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TEAM&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;days_open&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chance_of_a_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;miss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPO_FILES&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;FILES_I_TOUCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;REPO_FILES&lt;/span&gt;   &lt;span class="c1"&gt;# one edit misses me
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;miss&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days_open&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;# each change edits 2 files
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="nf"&gt;chance_of_a_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;# 5 changes    -&amp;gt; 14 percent
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;chance_of_a_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# 150 changes  -&amp;gt; 99 percent
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Drift&lt;/strong&gt; is the number of changes that landed on the shared trunk while my branch sat still. Five other developers, one change each per day, so drift is five changes per day.&lt;/p&gt;

&lt;p&gt;The chance of a conflict is the chance that at least one of those landed changes touched at least one of my eighteen files. Each landed change edits two files, so I take the chance of one edit missing me, and raise it to that power.&lt;/p&gt;

&lt;p&gt;Run it. Every figure below came out of that script, and each one was cross-checked with a 200,000-run simulation that shuffles the edits at random:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;How long the branch stayed open&lt;/th&gt;
&lt;th&gt;Changes that landed underneath it&lt;/th&gt;
&lt;th&gt;Chance of a conflict&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;1 working day&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;14.0%&lt;/strong&gt; &lt;em&gt;(simulated: 14.1%)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;1 working week&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;6 working weeks&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;98.9%&lt;/strong&gt; &lt;em&gt;(simulated: 98.9%)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the first and last rows next to each other. A branch that is one working day old has a &lt;strong&gt;14%&lt;/strong&gt; chance of hitting a conflict when it merges. Most days, nothing happens. You merge, and you carry on with your afternoon.&lt;/p&gt;

&lt;p&gt;A branch that is six weeks old has a &lt;strong&gt;98.9%&lt;/strong&gt; chance. Not most of the time. Essentially always.&lt;/p&gt;

&lt;p&gt;And here is the honest third number. &lt;strong&gt;Nothing about the work changed between those two rows.&lt;/strong&gt; The same eighteen files. The same feature. The same developer. The only thing that changed is that the branch was allowed to live thirty times longer.&lt;/p&gt;

&lt;p&gt;Look at the script again and notice what is missing. &lt;code&gt;FILES_I_TOUCH&lt;/code&gt; is fixed at eighteen in both runs. &lt;strong&gt;The size of the branch never appears in the calculation. Only its age.&lt;/strong&gt; A branch is a debt, and the interest on it is time.&lt;/p&gt;

&lt;p&gt;That is what continuous integration attacks. Not your tests, and not your tooling. The age of the oldest thing that has not been joined yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Continuous integration, written out
&lt;/h2&gt;

&lt;p&gt;So here is continuous integration in full. Notice that nothing in it is a product you can buy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# merge.py -- continuous integration, with nothing bought and nothing installed.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;integrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;my_change&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;trunk&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# 1. what everyone else already landed
&lt;/span&gt;    &lt;span class="n"&gt;joined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;my_change&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 2. join my work to it -- TODAY
&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;builds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;               &lt;span class="c1"&gt;# 3. does the whole thing still compile?
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the build broke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;tests_pass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;           &lt;span class="c1"&gt;# 4. does it still do what it did?
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the tests broke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trunk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# 5. now everyone builds on my work too
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step by step. Take what everyone else has already landed on the trunk. Join my work to it — not next month, today. Build it: does the whole thing still compile, with my change inside it? If it does not, stop, and say so out loud. Run the tests: does the whole thing still do what it did before? If it does not, stop, and say so out loud.&lt;/p&gt;

&lt;p&gt;And if both of those passed, push it back to the trunk. From this moment, everybody else is building on top of my work as well.&lt;/p&gt;

&lt;p&gt;That is all of it. Really it is two words. &lt;strong&gt;Every&lt;/strong&gt; change, and &lt;strong&gt;today&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A machine that runs your tests but lets branches live for six weeks is not doing this. It is just running your tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part that gets skipped: CI is a rule about people
&lt;/h2&gt;

&lt;p&gt;Let us say this plainly, because it is the part that gets skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous integration is a rule about how people work. The machine is only there to enforce it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which means a team can have every green tick in the world and still not be doing it. If the tests run on every proposed change, but those proposals stay open for three weeks, then the joining still happens once every three weeks. The machine is measuring something. It is not fixing anything.&lt;/p&gt;

&lt;p&gt;A green tick on a three-week-old branch proves that the branch was fine three weeks ago, against a trunk that no longer exists.&lt;/p&gt;

&lt;p&gt;And it is &lt;strong&gt;cheap to install, but expensive to keep&lt;/strong&gt;. Installing it takes an afternoon. Keeping it means the test suite has to stay fast, and it has to stay honest.&lt;/p&gt;

&lt;p&gt;The moment people stop believing a red result, you have made things worse than having no tests at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The arithmetic of a suite nobody believes
&lt;/h2&gt;

&lt;p&gt;That last sentence sounds like an opinion. It is arithmetic.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;flaky&lt;/strong&gt; test is one that sometimes fails even though the code is perfectly fine. Say you have four hundred tests. Say each one, on its own, fails wrongly about two times in every thousand runs.&lt;/p&gt;

&lt;p&gt;Two in a thousand sounds like nothing at all. But a build is only green when &lt;strong&gt;all four hundred&lt;/strong&gt; pass. Multiply it out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Wrong failures per test, per 1,000 runs&lt;/th&gt;
&lt;th&gt;Chance a perfectly good build shows red&lt;/th&gt;
&lt;th&gt;In other words&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 good build in every 5.5&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 good build in every 3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 good build in every 1.8&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At two in a thousand, &lt;strong&gt;more than half of your perfectly good builds show red.&lt;/strong&gt; So more than half the time, red means nothing.&lt;/p&gt;

&lt;p&gt;And then the number that actually costs you: &lt;strong&gt;zero&lt;/strong&gt;. That is how many people will look carefully at a red build, once they have learned that red usually means nothing. They press the button that runs it again.&lt;/p&gt;

&lt;p&gt;Now a real failure and a fake failure look exactly the same. Your suite has stopped being a signal. It is still running, it is still costing machine time, and it is no longer telling anybody anything.&lt;/p&gt;

&lt;p&gt;This is why "we installed it" is not the same as "we have it". Continuous integration is a habit that a machine happens to enforce, and habits decay.&lt;/p&gt;




&lt;h2&gt;
  
  
  Crossing the line: the artifact
&lt;/h2&gt;

&lt;p&gt;Now cross to the right hand side of that instant, and follow one green build all the way out.&lt;/p&gt;

&lt;p&gt;Here is the green build. The change is on the trunk, it compiled, the tests passed. Continuous integration has done its whole job, and &lt;strong&gt;it stops right here&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Continuous delivery starts here. The first thing it does is turn that build into one object. One package, with one identity, built exactly once. That object is called the &lt;strong&gt;artifact&lt;/strong&gt;, and from this moment it is the only thing that moves.&lt;/p&gt;

&lt;p&gt;The same artifact is then pushed into real environments. Not a rebuild. The same bytes, with different &lt;strong&gt;configuration&lt;/strong&gt; — addresses, secrets, sizes — handed to it from outside.&lt;/p&gt;

&lt;p&gt;And at the end there is production, with one step in front of it. In continuous delivery, that step is a button, and a person presses it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build once, promote the same bytes
&lt;/h2&gt;

&lt;p&gt;Here is what that means in code. This is the rule that carries the whole idea.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# build.py -- the artifact is built ONCE. Only the configuration changes.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_artifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# one build, one identity. This object is the only thing that ever ships.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;package&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# address, secret, size -- from OUTSIDE
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# the same bytes. Nothing is rebuilt.
&lt;/span&gt;
&lt;span class="n"&gt;art&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_artifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a91f4c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre-production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;art&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# 1 build, 3 deploys, identical bytes
&lt;/span&gt;
&lt;span class="c1"&gt;# Rebuild once per environment instead and you get 3 builds -- which is
# 2 chances for what reaches production to differ from what you tested.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the artifact from one commit. Package it, and give it an identity — a &lt;strong&gt;digest&lt;/strong&gt;, which is a short fingerprint of the contents, so you can point at exactly this thing and no other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Promoting&lt;/strong&gt; is a separate step, and this is the important line. Promoting takes the artifact and an environment. It loads that environment's configuration from outside the artifact. Then it deploys the artifact, unchanged.&lt;/p&gt;

&lt;p&gt;So one build goes to staging, then to pre-production, then to production. Three deployments, one set of bytes. &lt;strong&gt;The thing your users get is the exact thing your tests ran against.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now do it the other way. Build again for each environment. Three builds, which means two chances for what reaches production to be quietly different from what you tested. Nobody chooses that on purpose. It happens because rebuilding is the easy thing to write.&lt;/p&gt;




&lt;h2&gt;
  
  
  What continuous delivery demands
&lt;/h2&gt;

&lt;p&gt;Continuous delivery is not free, and the price is not money. It is a list of things that have to already be true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Required:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One artifact for every green build, and nothing else ever ships.&lt;/li&gt;
&lt;li&gt;Configuration comes from outside that artifact, always.&lt;/li&gt;
&lt;li&gt;Database changes that work with both the old and the new version of the code, because for a few minutes both of them are running.&lt;/li&gt;
&lt;li&gt;A rollback you have actually used, on a real day. Not a paragraph in a document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;And here is what has to go:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers that somebody edits by hand.&lt;/li&gt;
&lt;li&gt;A separate build for each environment.&lt;/li&gt;
&lt;li&gt;A long-lived release branch.&lt;/li&gt;
&lt;li&gt;A freeze week before every release.&lt;/li&gt;
&lt;li&gt;A page of manual steps written down in a document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that list, continuous delivery is only a faster way to ship the same bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  One boundary, so you know what this is not
&lt;/h2&gt;

&lt;p&gt;There is a whole subject sitting next to this one, and it is worth naming so you can see the edge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How a release is rolled out&lt;/strong&gt; is that other subject, and it is covered elsewhere on this site: two full production stacks with the traffic flipped between them, a small share of users getting the new version first, and requests already in flight being allowed to finish before the old version stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shipping code switched off and turning it on later&lt;/strong&gt; is a third subject, also covered elsewhere, and it is not the same as either half here.&lt;/p&gt;

&lt;p&gt;All of them are real, and all of them matter. This article is about something before all of them: how often work is joined together, whether every green build becomes one artifact, and how far that artifact travels on its own.&lt;/p&gt;

&lt;p&gt;The pipeline, and the artifact. Not the rollout. &lt;strong&gt;This article stops at the artifact.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The third D
&lt;/h2&gt;

&lt;p&gt;And now the third word, which is where almost every argument about CI/CD actually comes from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery&lt;/strong&gt; means every green build is &lt;em&gt;ready&lt;/em&gt; to go to production. It has been built once, it has been through the environments, and it is sitting at the door. A person decides when it goes. The team can ship ten times a day if they want to, and they can also decide not to ship anything for a week. &lt;strong&gt;Ready is the promise. Released is a choice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous deployment&lt;/strong&gt; removes the person. Green means it goes. Nobody presses anything.&lt;/p&gt;

&lt;p&gt;Both of them are written CD, which is exactly why people argue past each other. One person is defending a discipline, the other is defending an automation, and they both think the letters mean the same thing.&lt;/p&gt;

&lt;p&gt;They are also not the same difficulty at all. Delivery needs discipline. Deployment needs everything delivery needs, &lt;strong&gt;plus&lt;/strong&gt; a way to notice a bad release and undo it without a human — because there is no longer a human in the loop to notice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The difference is often one line
&lt;/h2&gt;

&lt;p&gt;Inside a real pipeline, the difference between those two is often exactly one setting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# promote.py -- continuous DELIVERY and continuous DEPLOYMENT, in one file.
&lt;/span&gt;
&lt;span class="n"&gt;REQUIRE_A_HUMAN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;      &lt;span class="c1"&gt;# True: delivery.   False: deployment.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_green_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;smoke_tests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging is unhappy -- nothing goes anywhere&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;REQUIRE_A_HUMAN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;wait_for_the_button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# DELIVERY: ready, not released
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# DEPLOYMENT: it ships itself
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the top there is one setting: require a human, true or false.&lt;/p&gt;

&lt;p&gt;On a green build, promote the artifact to staging, and run the quick checks there. If staging is unhappy, stop, and nothing goes anywhere.&lt;/p&gt;

&lt;p&gt;And now the branch. If require-a-human is &lt;strong&gt;true&lt;/strong&gt;, you wait for the button. The artifact is finished, tested, and standing still. That is continuous delivery: ready, but not released.&lt;/p&gt;

&lt;p&gt;If require-a-human is &lt;strong&gt;false&lt;/strong&gt;, the same artifact goes to production right there, on the same green build, with nobody watching. That is continuous deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery ends at a button that nobody is obliged to press. Continuous deployment deletes the button.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note what did &lt;em&gt;not&lt;/em&gt; change between the two paths: the artifact, the promotion, the checks. Everything above that setting is the same work. That is why you cannot skip delivery on the way to deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the size of a release matters, on a bad day
&lt;/h2&gt;

&lt;p&gt;There is one more reason to care, and it only shows up when something goes wrong.&lt;/p&gt;

&lt;p&gt;Suppose your six developers finish one change each per day, and you release once a quarter. That release contains &lt;strong&gt;378 changes&lt;/strong&gt;. Then something breaks in production. You now have 378 suspects. Finding the one, by repeatedly cutting the list in half, takes about &lt;strong&gt;9 rounds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now release once a day instead. That release contains &lt;strong&gt;6 changes&lt;/strong&gt;. Six suspects. About &lt;strong&gt;3 rounds&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Release rhythm&lt;/th&gt;
&lt;th&gt;Changes in one release&lt;/th&gt;
&lt;th&gt;Rounds of halving to find the culprit&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Once a quarter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;378&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~9&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Once a day&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Difference&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;63×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3×&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same team. The same code. The same bugs. The only thing that changed is how much work was allowed to pile up between one release and the next.&lt;/p&gt;

&lt;p&gt;This is not just arithmetic on a page, either. The multi-year &lt;em&gt;Accelerate&lt;/em&gt; research found the same direction in real organisations: teams that release in small batches, often, also recover faster and fail less. Small batches are not a nicety on top of the pipeline. They are most of what the pipeline buys you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same two halves, when part of what you ship is a model
&lt;/h2&gt;

&lt;p&gt;If your system includes a trained model, both halves still apply, and one of them gets harder.&lt;/p&gt;

&lt;p&gt;The left half does not change at all. Prompts, retrieval settings, evaluation scripts and serving code are all just code on a trunk, and a branch holding them is exactly as expensive with age as any other branch.&lt;/p&gt;

&lt;p&gt;The right half is where people quietly break the rule. The artifact stops being one object. The code is packaged once, but the model weights are fetched at start-up from wherever the latest ones happen to live, and the prompt text is edited in a console by hand. Now three things ship on three different schedules, and none of them has one identity. When output quality drops on a Tuesday, you cannot say what changed, because "what changed" was never a single thing you could point at.&lt;/p&gt;

&lt;p&gt;The fix is the same sentence as before, applied honestly: &lt;strong&gt;one artifact per green build.&lt;/strong&gt; Pin the weights by digest, put the prompt in the package, and let configuration — endpoints, sizes, keys — come from outside. Then a bad Tuesday has one identity attached to it, and rolling back means going back to one object rather than reconstructing three.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three questions about your own team
&lt;/h2&gt;

&lt;p&gt;You can answer these this afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First.&lt;/strong&gt; How old is the oldest piece of work that has not been joined to the trunk yet? If you answer in weeks, you do not have continuous integration, no matter what runs on your proposed changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second.&lt;/strong&gt; Does every green build produce exactly one artifact, which is never rebuilt on its way out? If not, you do not have continuous delivery yet — because there is nothing being delivered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third.&lt;/strong&gt; What is the last step before production? If it is a button, that is delivery. If there is no step at all, that is deployment. If it is a person following instructions written in a document, then it is neither of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Continuous integration&lt;/th&gt;
&lt;th&gt;Continuous delivery&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;It is a rule about&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;merge&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;artifact&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;It looks&lt;/td&gt;
&lt;td&gt;backwards, at joining work&lt;/td&gt;
&lt;td&gt;forwards, at shipping it&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Its question&lt;/td&gt;
&lt;td&gt;How old is the oldest unjoined work?&lt;/td&gt;
&lt;td&gt;How much stands between this build and a user?&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;A good answer&lt;/td&gt;
&lt;td&gt;hours&lt;/td&gt;
&lt;td&gt;one button&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What it costs&lt;/td&gt;
&lt;td&gt;cheap to install, expensive to keep honest&lt;/td&gt;
&lt;td&gt;discipline: config outside, two-way migrations, a used rollback&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;How it fails quietly&lt;/td&gt;
&lt;td&gt;green ticks on three-week-old branches&lt;/td&gt;
&lt;td&gt;a rebuild per environment&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Continuous integration is a rule about the merge.&lt;/strong&gt; Everybody's work joins the same trunk today, and every join is built and tested, so a break is found while the change is still small enough to understand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous delivery is a rule about the artifact.&lt;/strong&gt; Every green build becomes one package, built once, that travels the environments unchanged, until it is standing one press away from production.&lt;/p&gt;

&lt;p&gt;And the D that everybody argues about is a third thing. Delivery means always ready. Deployment means the person is gone as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can have the first without the second, and almost everybody does.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On branch age and merge day&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paul Hammant and contributors, &lt;em&gt;Trunk Based Development&lt;/em&gt; (trunkbaseddevelopment.com) — the short-lived-branch rule and why branch lifetime, not branch size, is the thing to manage: &lt;a href="https://trunkbaseddevelopment.com/" rel="noopener noreferrer"&gt;trunkbaseddevelopment.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On continuous integration as a rule about people&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kent Beck, &lt;em&gt;Extreme Programming Explained: Embrace Change&lt;/em&gt; (Addison-Wesley, 1999; 2nd ed. 2004) — the origin of "integrate continuously" as a team practice rather than a piece of infrastructure.&lt;/li&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;Continuous Integration&lt;/em&gt; (martinfowler.com, 2006) — the canonical definition, including "everyone commits to mainline every day": &lt;a href="https://martinfowler.com/articles/continuousIntegration.html" rel="noopener noreferrer"&gt;martinfowler.com/articles/continuousIntegration.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On a suite people stop believing&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;Eradicating Non-Determinism in Tests&lt;/em&gt; (martinfowler.com, 2011) — why a suite that fails at random destroys the trust that makes it worth running at all: &lt;a href="https://martinfowler.com/articles/nonDeterminism.html" rel="noopener noreferrer"&gt;martinfowler.com/articles/nonDeterminism.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the artifact, and build-once-promote&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jez Humble and David Farley, &lt;em&gt;Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation&lt;/em&gt; (Addison-Wesley, 2010) — the deployment pipeline, building binaries exactly once, and keeping configuration outside the artifact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On the third D: delivery versus deployment&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;em&gt;ContinuousDelivery&lt;/em&gt; and &lt;em&gt;Continuous Delivery vs Continuous Deployment&lt;/em&gt; (martinfowler.com) — the ready-versus-released distinction stated directly: &lt;a href="https://martinfowler.com/bliki/ContinuousDelivery.html" rel="noopener noreferrer"&gt;martinfowler.com/bliki/ContinuousDelivery.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On batch size and release rhythm&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nicole Forsgren, Jez Humble and Gene Kim, &lt;em&gt;Accelerate: The Science of Lean Software and DevOps&lt;/em&gt; (IT Revolution, 2018) — the measured link between small batches, deployment frequency, lead time and recovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you would expect is missing, say so in the comments and I will add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the full episode:&lt;/strong&gt; &lt;a href="https://youtu.be/J0JcRX0jokM" rel="noopener noreferrer"&gt;Continuous Integration vs Continuous Delivery on YouTube&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Service Boundaries: Where Does the Line Between Two Services Actually Go?</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:10:11 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/service-boundaries-where-does-the-line-between-two-services-actually-go-3akh</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/service-boundaries-where-does-the-line-between-two-services-actually-go-3akh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/e-sjhV7CymE" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/service-boundaries-where-does-the-line-between-two-services-actually-go?id=182" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have decided to split. That part is not in question here — this article takes it as given and asks only the next question, the one that actually costs money: &lt;strong&gt;where does the line go?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The split almost everyone draws first is by &lt;strong&gt;noun&lt;/strong&gt; — one service per thing the business talks about. Order, user, product, pricing. Or by the &lt;strong&gt;org chart&lt;/strong&gt; — one service per team. Both are visible on day one, on a whiteboard, before anyone has written a line of code. That is exactly why they get drawn, and it is also why neither of them is a boundary.&lt;/p&gt;

&lt;p&gt;Six months later you have four services, and one page load needs all four.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tell: a chain of synchronous calls
&lt;/h2&gt;

&lt;p&gt;Here is the shape you are looking for. &lt;code&gt;order_svc&lt;/code&gt; owns the orders table and nothing else on the page. So it asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# order_svc - GET /orders/{oid}
# it owns the orders table. it owns nothing else on this page.
&lt;/span&gt;
&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/orders/{oid}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM orders WHERE id=?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# the customer name is not ours. ask the user service, and wait.
&lt;/span&gt;    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;USER_SVC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/users/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# the title is not ours either - and this one runs PER LINE ITEM.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_SVC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/products/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# ...and product_svc, to answer that, asks pricing_svc itself.
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;full_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that loop again. &lt;strong&gt;The fan-out is not a fixed number — it grows with the data on the page.&lt;/strong&gt; &lt;code&gt;product_svc&lt;/code&gt; is asked once &lt;em&gt;per line item&lt;/em&gt;, and each of those asks &lt;code&gt;pricing_svc&lt;/code&gt;. On the rig used for the episode, a one-line-item order produced &lt;strong&gt;3&lt;/strong&gt; outbound calls; the two-line-item demo order produced &lt;strong&gt;5&lt;/strong&gt;. The formula is &lt;code&gt;1 + 2 × line_items&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first version of this argument said "three services, three calls" and drew a single product fetch. Both were wrong. Any article that says three services means three calls is counting boxes on a diagram, not counting requests.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;5 and 6 are both correct numbers that mean different things.&lt;/strong&gt; The &lt;code&gt;X-Hops&lt;/code&gt; header reports &lt;strong&gt;5 outbound&lt;/strong&gt; calls. The rig's total &lt;em&gt;inbound&lt;/em&gt; count is &lt;strong&gt;6&lt;/strong&gt;, because the page load into &lt;code&gt;order_svc&lt;/code&gt; is itself a request that can fail. Which one you use matters a great deal in a moment.&lt;/p&gt;

&lt;p&gt;That is not four services. It is one distributed monolith: the latency of four, the failure modes of four, and the independence of none.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the chain actually costs
&lt;/h2&gt;

&lt;p&gt;Every figure below was measured on a real four-service rig, 2,000 requests per build, and the naive, collapsed, and modular builds return &lt;strong&gt;byte-for-byte identical responses&lt;/strong&gt; (&lt;code&gt;diff&lt;/code&gt; exit 0). Same feature, three architectures.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What was measured&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Naive chain (4 processes, HTTP) — mean / p95&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;10.12 ms&lt;/strong&gt; / 12.21 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cost of each extra network hop&lt;/td&gt;
&lt;td&gt;~&lt;strong&gt;1.80 ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Collapsed (1 process, 1 database) — mean / p95&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.11 ms&lt;/strong&gt; / 1.18 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Speed-up, identical response bytes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;9.09×&lt;/strong&gt; (9.01 ms/req saved)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Actual work — every DB read, all four services&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;140 µs = 1.38%&lt;/strong&gt; of the request&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;order_svc&lt;/code&gt; blocked on a socket&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;87.96%&lt;/strong&gt; of the request&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Outbound calls — 1-item order / 2-item order&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; / &lt;strong&gt;5&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Boundary &lt;em&gt;in-process&lt;/em&gt; vs no boundary&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+30 µs (+2.7%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Boundary &lt;em&gt;over the network&lt;/em&gt; vs no boundary&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+9.01 ms (+809%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two lines in that table are the whole argument. The real work — every database read in all four services — is &lt;strong&gt;1.38%&lt;/strong&gt; of the request. &lt;code&gt;order_svc&lt;/code&gt; spends &lt;strong&gt;88%&lt;/strong&gt; of its life blocked on a socket waiting for a service to answer a question it could have answered itself.&lt;/p&gt;

&lt;p&gt;And a boundary is not intrinsically expensive. The same wall, drawn &lt;em&gt;inside one process&lt;/em&gt;, costs &lt;strong&gt;30 microseconds&lt;/strong&gt;. Drawn &lt;em&gt;across a network&lt;/em&gt;, it costs &lt;strong&gt;9 milliseconds&lt;/strong&gt; — three hundred times more, for the same separation of concerns. You are not paying for the boundary. You are paying for the transport you chose to put it on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The availability bill: right rule, wrong exponent
&lt;/h2&gt;

&lt;p&gt;Everyone knows the multiplication rule. Four services at 99.9% each, in a chain where all must succeed:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.999⁴ = 99.6006%&lt;/code&gt; → &lt;strong&gt;172.5 minutes of downtime per month&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That figure is arithmetically correct — a 1,000,000-trial Monte Carlo matched it. It is also the wrong number, because the read path does not involve four things that must succeed. It involves &lt;strong&gt;six&lt;/strong&gt;: the request into &lt;code&gt;order_svc&lt;/code&gt;, plus the five calls it sets off.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.999⁶ = 99.4015%&lt;/code&gt; → &lt;strong&gt;258.6 minutes per month&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is an &lt;strong&gt;86-minute-a-month gap&lt;/strong&gt;, and it is hidden by counting boxes on a diagram instead of counting what has to answer. Availability is raised to the number of things that must &lt;strong&gt;succeed&lt;/strong&gt;, not the number of boxes you drew.&lt;/p&gt;

&lt;p&gt;One honest note, because the temptation to overclaim here is strong: the measured rig matched theory within one standard error once a rig artefact was removed (a &lt;code&gt;sqlite3&lt;/code&gt; connection shared across the threadpool produced phantom 404s and 500s — 36% of all observed failures, all of them pushing the rate down). Measurement did not beat the theory. The interesting result is the &lt;strong&gt;exponent&lt;/strong&gt;, not a discrepancy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The deploy story is worse than you have been told
&lt;/h2&gt;

&lt;p&gt;Every article on this subject says: deploy them in the right order. Measured on a one-field rename across the boundary — &lt;code&gt;name&lt;/code&gt; → &lt;code&gt;full_name&lt;/code&gt; in &lt;code&gt;user_svc&lt;/code&gt;, which &lt;code&gt;order_svc&lt;/code&gt; reads — &lt;strong&gt;both orders fail&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploy &lt;code&gt;user_svc&lt;/code&gt; first → &lt;code&gt;order_svc&lt;/code&gt; raises &lt;code&gt;KeyError: 'name'&lt;/code&gt; → &lt;strong&gt;HTTP 500&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Deploy &lt;code&gt;order_svc&lt;/code&gt; first → &lt;code&gt;KeyError: 'full_name'&lt;/code&gt; → &lt;strong&gt;HTTP 500&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no ordering that works, because the two versions are never simultaneously correct. The only thing that works is a &lt;strong&gt;three-deploy expand/contract sequence&lt;/strong&gt;: emit both fields, migrate the reader, then drop the old field. The seeded history on the rig shows exactly that shape — a commit reading &lt;code&gt;rename name -&amp;gt; full_name (expand: emit both)&lt;/code&gt; and, later, &lt;code&gt;(contract: drop name)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the real cost of that boundary is not "coordination". It is &lt;strong&gt;three releases to rename one field&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  So what actually decides a boundary?
&lt;/h2&gt;

&lt;p&gt;Two things, and neither of them is a noun.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Who owns the data
&lt;/h3&gt;

&lt;p&gt;Exactly one service may &lt;strong&gt;write&lt;/strong&gt; a given fact. Everyone else asks. The interesting move is not making the service smaller — it is deciding, per fact, whose fact it is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the fix is not a smaller service. it is deciding who owns each fact.
&lt;/span&gt;
&lt;span class="c1"&gt;# WRONG - the order page asks the product service what the price is.
#         that is the price TODAY. the customer paid LAST MONTH.
&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_SVC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/products/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# RIGHT - the price charged is not a product fact at all.
#         it is a fact about THIS order, and it never changes again.
&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO orders (user_id, product_id, price_paid, title_at_sale)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price_now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title_now&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# reading that order back now touches one table, in one process.
# the call to pricing did not get faster. it stopped existing.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last comment is the point. &lt;strong&gt;The price the customer paid is a fact about the order, not about the product.&lt;/strong&gt; Once you see that, the call to &lt;code&gt;pricing_svc&lt;/code&gt; on the read path is not slow — it is &lt;em&gt;wrong&lt;/em&gt;, and it does not need optimising, it needs deleting. A correct ownership decision does not make a call faster. It removes the call.&lt;/p&gt;

&lt;p&gt;This is also, incidentally, a correctness fix and not only a performance one: asking the product service for "the price" on an order page returns the price &lt;strong&gt;today&lt;/strong&gt;, for an order placed last month.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. What changes together
&lt;/h3&gt;

&lt;p&gt;Two things that are always released in the same commit are one thing wearing two names. You do not have to guess at this — your version control has been recording the answer for years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git log --name-only --pretty=format:%H | python3 cochange.py

  pair                            commits touching BOTH      share
  order_svc   + user_svc                    6               75.0%
  product_svc + pricing_svc                 1               25.0%
  order_svc   + product_svc                 0                0.0%
  order_svc   + pricing_svc                 0                0.0%

# 75%, and every user_svc commit touched order_svc too: one unit of change.
# 0% does NOT automatically mean the wall is earning its keep -
#   check whether that pair is on the READ PATH before you trust it.
#   both zero pairs here are on it, and they were resolved differently.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;order_svc&lt;/code&gt; and &lt;code&gt;user_svc&lt;/code&gt; co-change &lt;strong&gt;75%&lt;/strong&gt; of the time, and every single &lt;code&gt;user_svc&lt;/code&gt; commit also touched &lt;code&gt;order_svc&lt;/code&gt;. That is not two services. That is one unit of change with a network cable running through the middle of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two mechanisms disagree, and you should say so
&lt;/h3&gt;

&lt;p&gt;Look at the table again. Co-change says nothing at all about &lt;code&gt;order_svc + product_svc&lt;/code&gt; — &lt;strong&gt;0%&lt;/strong&gt;. So the co-change test does &lt;em&gt;not&lt;/em&gt; argue for merging order and product; the &lt;strong&gt;read-path fan-out&lt;/strong&gt; does. Two mechanisms, pointing different ways, on the same pair.&lt;/p&gt;

&lt;p&gt;Most writing on this topic presents the rules as if they always agree. They do not. When yours disagree, name which one you are following and why. Here: order and product get merged on fan-out grounds, and order and pricing stay apart despite an identical 0% because pricing comes off the read path entirely once &lt;code&gt;price_paid&lt;/code&gt; is owned by the order.&lt;/p&gt;




&lt;h2&gt;
  
  
  The line, moved
&lt;/h2&gt;

&lt;p&gt;Merge &lt;strong&gt;order + user + product&lt;/strong&gt; into one deployable — three modules, one process, one database. Keep &lt;strong&gt;pricing&lt;/strong&gt; a real service: different team, different release day, and crucially &lt;strong&gt;it is not on the read path at all&lt;/strong&gt; any more, because the order stores &lt;code&gt;price_paid&lt;/code&gt; and &lt;code&gt;title_at_sale&lt;/code&gt; at the time of sale. Its only remaining edge is a one-way, nightly, asynchronous one.&lt;/p&gt;

&lt;p&gt;Losing the network does not mean losing the wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ONE process. ONE deployable. the boundary is still real.
&lt;/span&gt;
&lt;span class="c1"&gt;# shop/users/api.py - the only way in. no table name ever leaves this file.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_row_to_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id, name FROM users WHERE id=?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# shop/orders/service.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;shop.users.api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_user&lt;/span&gt;       &lt;span class="c1"&gt;# a function, not a URL
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# our own table
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_paid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;# our own column
&lt;/span&gt;
&lt;span class="c1"&gt;# and the wall is enforced, not merely agreed:
#   a sqlite authorizer refuses any statement from shop.orders that
#   names the users table. a violation raises. it does not lint.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last three lines are what separates this from wishful thinking. A module boundary that lives in a code-review convention is not a boundary; it is a hope. A SQLite authorizer that &lt;strong&gt;raises&lt;/strong&gt; when &lt;code&gt;shop.orders&lt;/code&gt; names the &lt;code&gt;users&lt;/code&gt; table is a boundary, and it costs 30 microseconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three architectures, one feature
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;/th&gt;
      &lt;th&gt;Four services (by noun)&lt;/th&gt;
      &lt;th&gt;One deployable, no wall&lt;/th&gt;
      &lt;th&gt;One deployable, modules&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Mean latency&lt;/td&gt;
&lt;td&gt;10.12 ms&lt;/td&gt;
&lt;td&gt;1.11 ms&lt;/td&gt;
&lt;td&gt;1.14 ms&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cost of the boundary&lt;/td&gt;
&lt;td&gt;+9.01 ms (+809%)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;+30 µs (+2.7%)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Things that must succeed&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Availability at 99.9% each&lt;/td&gt;
&lt;td&gt;99.4015%&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Rename one field across the line&lt;/td&gt;
&lt;td&gt;3 deploys (expand/contract)&lt;/td&gt;
&lt;td&gt;1 commit&lt;/td&gt;
&lt;td&gt;1 commit&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Wall enforced by&lt;/td&gt;
&lt;td&gt;the network&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;an authorizer that raises&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Moving the line later&lt;/td&gt;
&lt;td&gt;data migration&lt;/td&gt;
&lt;td&gt;refactor&lt;/td&gt;
&lt;td&gt;refactor&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The costs, stated plainly
&lt;/h3&gt;

&lt;p&gt;This is not free, and the honest version says so. You end up with &lt;strong&gt;fewer and larger services&lt;/strong&gt; than the diagram wanted, which will feel like a regression to anyone who counts services. Moving a line later is a &lt;strong&gt;data migration&lt;/strong&gt;, not a refactor — merging two databases is real work. And a big in-process module still needs someone to defend the wall, because the compiler will happily let you reach across it.&lt;/p&gt;

&lt;p&gt;Which is exactly why the reversible move is to draw the line &lt;strong&gt;inside one deployable first&lt;/strong&gt;: one module, one function as the only way in, and the wall enforced mechanically. If the line turns out to be in the wrong place, you move it with a refactor instead of a migration. If it turns out to be right, promoting it to a process later is a small, well-understood step — and you will have real co-change data by then to prove it.&lt;/p&gt;

&lt;p&gt;Keeping two databases in step is its own problem, with its own name and its own article. Do not sign up for it by accident on a whiteboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same two tests, on an LLM-serving stack
&lt;/h2&gt;

&lt;p&gt;None of this is specific to shopping carts, and the AI stack is where the noun-split is currently being repeated most enthusiastically. A typical serving diagram has a gateway, an orchestrator, a retriever, a reranker, a model server, and an eval service — six boxes, one per noun, drawn on day one.&lt;/p&gt;

&lt;p&gt;Run the two tests on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changes together?&lt;/strong&gt; In practice the prompt template, the few-shot examples, and the output parser change in the same commit, essentially always. Change the prompt and the parser breaks; tighten the schema and the prompt has to say so. That is a co-change rate near 100% — one unit of change. Split it into a "prompt service" and a "parsing service" and you have bought yourself three deploys to add a field, for exactly the reason &lt;code&gt;name&lt;/code&gt; → &lt;code&gt;full_name&lt;/code&gt; needed three.&lt;/p&gt;

&lt;p&gt;The chunker and the embedder are the same story from the other side: change the embedding model and every vector in the index is invalid. They ship together or the index is quietly wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who owns the data?&lt;/strong&gt; The vector index has exactly one legitimate writer — whichever service owns the ingest path. Everyone else reads. And the &lt;code&gt;price_paid&lt;/code&gt; lesson transfers directly: when you log a trace, store the &lt;strong&gt;prompt text, the model version, and the retrieved chunk ids as they were at call time&lt;/strong&gt;, on the trace row. They are facts about &lt;em&gt;that call&lt;/em&gt;, not about the retriever's current state. A trace that has to ask the retrieval service what it &lt;em&gt;would&lt;/em&gt; return today cannot be replayed, and your eval numbers will drift underneath you the same way a re-fetched price does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the fan-out?&lt;/strong&gt; A request that goes gateway → orchestrator → retriever → reranker → model server synchronously has the same six-exponent availability bill as the shop did, and every hop lands directly on &lt;strong&gt;time-to-first-token&lt;/strong&gt; — the number your users actually feel. The 88%-blocked-on-a-socket figure is, if anything, kinder than what you will measure here, because a rerank hop is not 1.8 ms.&lt;/p&gt;

&lt;p&gt;And which box genuinely earns its own process? &lt;strong&gt;The model server.&lt;/strong&gt; Not because it is a different noun, but because it fails all three of the reasons the others failed: it scales on a completely different axis (GPU memory, batch size, KV cache), it releases on a different cadence, and it does not co-change with the prompt at all. That is what an earned boundary looks like — different data owner, different unit of change, different scaling axis. Not a different word on a whiteboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A boundary you have to call synchronously on every request is not a boundary. It is a network hop you added to a function call.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The point of the line was never tidiness or a tidy diagram. It was &lt;strong&gt;independent deployability&lt;/strong&gt; — can these two things ship apart, on different days, by different people, with nobody coordinating? If the answer is no, you do not have two services. You have one system, and the line you drew is a cost you are paying for nothing.&lt;/p&gt;

&lt;p&gt;So before the next split, ask the two questions that do not appear on the whiteboard: &lt;strong&gt;who owns this fact&lt;/strong&gt;, and &lt;strong&gt;what changes with it&lt;/strong&gt;. Then draw the line inside one deployable, enforce it with something that raises rather than something that comments, and let it earn its process later.&lt;/p&gt;




&lt;h2&gt;
  
  
  References and further reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The premise — you have decided to split, and now the line has to go somewhere&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sam Newman, &lt;em&gt;Building Microservices&lt;/em&gt; (O'Reilly, 2nd ed. 2021) — independent deployability as the defining property of a service, and the case that a boundary you cannot ship apart is not one.&lt;/li&gt;
&lt;li&gt;Martin Fowler, &lt;a href="https://martinfowler.com/bliki/MicroservicePremium.html" rel="noopener noreferrer"&gt;&lt;em&gt;MicroservicePremium&lt;/em&gt;&lt;/a&gt; (martinfowler.com, 2015) — names the fixed cost every distributed boundary charges before it delivers any benefit, which is the ledger this whole article is keeping.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The tell — a synchronous chain across four services&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Martin Fowler, &lt;a href="https://martinfowler.com/bliki/FirstLaw.html" rel="noopener noreferrer"&gt;&lt;em&gt;First Law of Distributed Object Design&lt;/em&gt;&lt;/a&gt; (martinfowler.com, 2003) — "don't distribute your objects": the original statement that a remote call is not a local call with extra latency, it is a different thing.&lt;/li&gt;
&lt;li&gt;Michael T. Nygard, &lt;em&gt;Release It!&lt;/em&gt; (Pragmatic Bookshelf, 2nd ed. 2018) — integration points as the leading source of instability, and how a synchronous dependency turns one service's bad day into everyone's.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The availability arithmetic&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Betsy Beyer et al., eds., &lt;a href="https://sre.google/sre-book/embracing-risk/" rel="noopener noreferrer"&gt;&lt;em&gt;Site Reliability Engineering&lt;/em&gt;&lt;/a&gt; (O'Reilly, 2016), ch. "Embracing Risk" — availability targets, error budgets, and why a service's own target has to account for everything it depends on to answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Decider 1 — who owns the data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chris Richardson, &lt;a href="https://microservices.io/patterns/data/database-per-service.html" rel="noopener noreferrer"&gt;&lt;em&gt;Pattern: Database per Service&lt;/em&gt;&lt;/a&gt; (microservices.io) — one writer per fact, stated as a pattern with its trade-offs, including the queries it makes hard.&lt;/li&gt;
&lt;li&gt;Eric Evans, &lt;em&gt;Domain-Driven Design&lt;/em&gt; (Addison-Wesley, 2003) — bounded context: the same word means different things to different parts of the business, which is why "price" belongs to the order and not to the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Decider 2 — what changes together&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;David L. Parnas, &lt;a href="https://dl.acm.org/doi/10.1145/361598.361623" rel="noopener noreferrer"&gt;&lt;em&gt;On the Criteria To Be Used in Decomposing Systems into Modules&lt;/em&gt;&lt;/a&gt; (Communications of the ACM, 1972) — decompose by what is likely to change, not by processing steps; the ancestor of the co-change test.&lt;/li&gt;
&lt;li&gt;Adam Tornhill, &lt;em&gt;Your Code as a Crime Scene&lt;/em&gt; (Pragmatic Bookshelf, 2015) — mining version-control history for change coupling, i.e. how to get the &lt;code&gt;git log&lt;/code&gt; table above out of your own repository.&lt;/li&gt;
&lt;li&gt;Melvin E. Conway, &lt;a href="https://www.melconway.com/Home/Committees_Paper.html" rel="noopener noreferrer"&gt;&lt;em&gt;How Do Committees Invent?&lt;/em&gt;&lt;/a&gt; (Datamation, 1968) — the org-chart split, and why it keeps getting drawn whether or not it is the right line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The three-deploy rename&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Danilo Sato, &lt;a href="https://martinfowler.com/bliki/ParallelChange.html" rel="noopener noreferrer"&gt;&lt;em&gt;ParallelChange&lt;/em&gt;&lt;/a&gt; (martinfowler.com, 2014) — expand / migrate / contract, the only sequence that survives a rename across a boundary when neither deploy order works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a reference you'd expect is missing, say so in the comments and I'll add it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; the &lt;a href="https://youtube.com/shorts/tXDdgNx96uc" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; draws the four-service shop and names the two deciders; the &lt;a href="https://youtu.be/e-sjhV7CymE" rel="noopener noreferrer"&gt;full episode&lt;/a&gt; runs the ownership fix, the co-change analysis, and the in-process module wall on a rig that really executes.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
