<?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>Background Jobs Without a Broker: Your Worker Is Holding a Database Connection for 40 Minutes</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:07:08 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/background-jobs-without-a-broker-your-worker-is-holding-a-database-connection-for-40-minutes-268f</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/background-jobs-without-a-broker-your-worker-is-holding-a-database-connection-for-40-minutes-268f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/W2gGx9ABv4s" 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/background-jobs-without-a-broker-your-worker-is-holding-a-database-connection-for-40-minutes?id=159" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is a real row from a real database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pid  | application_name | state               | duration
-----+------------------+---------------------+-----------
2841 | worker-07        | idle in transaction | 00:40:12
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One worker. One open connection. State: &lt;code&gt;idle in transaction&lt;/code&gt;. Forty minutes and twelve seconds.&lt;/p&gt;

&lt;p&gt;The job it was running finished long ago. Nothing in the code has crashed. There is no error in any log file. But two things are going wrong at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New connections are starting to be refused.&lt;/li&gt;
&lt;li&gt;The cleanup job inside the database is running, and freeing nothing. Not in one table. In &lt;strong&gt;every&lt;/strong&gt; table of that database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is about how an ordinary background job ends up here, and how to write one that never can.&lt;/p&gt;

&lt;p&gt;Two sentences carry the whole thing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A job is a row in a table, not a function call.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A connection is not a transaction.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only remember those two, you will already write better workers than most teams.&lt;/p&gt;

&lt;p&gt;Every number and every error string below was measured against a real &lt;strong&gt;PostgreSQL 16.14&lt;/strong&gt; and &lt;strong&gt;SQLAlchemy 2.0.52&lt;/strong&gt;. One of them contradicts what almost everybody says. I will show you that one.&lt;/p&gt;




&lt;h2&gt;
  
  
  First, the picture that kills the confusion
&lt;/h2&gt;

&lt;p&gt;Think of a bank with a long counter.&lt;/p&gt;

&lt;p&gt;On the left, a person is sitting at it. The bank has a fixed number of seats. Taking one costs exactly one seat. It costs one seat whether you are writing on a form, waiting for a phone call, or finished ten minutes ago and simply stayed. When every seat is taken, the next person who walks in is turned away.&lt;/p&gt;

&lt;p&gt;Now look at the right. Same counter, same seat. But this time a large &lt;strong&gt;ledger book&lt;/strong&gt; is lying wide open. An open page means: not signed off yet. And while any page is open, the clerk behind cannot file old paper away, because your open page might still refer to it. One open page holds up the filing for the whole bank.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;seat&lt;/strong&gt; is a &lt;strong&gt;connection&lt;/strong&gt;: one open line between your program and the database.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;open page&lt;/strong&gt; is a &lt;strong&gt;transaction&lt;/strong&gt;: a group of changes the database treats as one unit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your worker takes both. Only one of them is cheap.&lt;/p&gt;




&lt;h2&gt;
  
  
  The system: four boxes
&lt;/h2&gt;

&lt;p&gt;Four boxes, and nothing else appears for the rest of this post.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The browser&lt;/strong&gt; — one person, one tab. They upload a large file, and later they ask: is it ready yet?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your API server&lt;/strong&gt; — one uvicorn process. It must answer in milliseconds, so it must not do slow work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres&lt;/strong&gt; — one server, with one table called &lt;code&gt;jobs&lt;/code&gt;, one row for each piece of work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The worker&lt;/strong&gt; — a separate Python process. Thirty of them, doing the slow work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the lines. The browser talks to the API server and gets an answer back. The API server writes the job row into Postgres and reads it back later. The worker claims a row and updates it.&lt;/p&gt;

&lt;p&gt;Now look at what is &lt;strong&gt;missing&lt;/strong&gt;. There is no line between the browser and the worker. They never speak. Everything they share, they share through one row.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where almost every codebase starts
&lt;/h2&gt;

&lt;p&gt;It is only 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="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/uploads&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;create_upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UploadFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&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="n"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&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="nc"&gt;Upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&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;converting&lt;/span&gt;&lt;span class="sh"&gt;"&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;add&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;convert&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="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# one line. the request returns now.
&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;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;row&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;converting&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;p&gt;A file comes in. We insert a row that says &lt;code&gt;converting&lt;/code&gt;, and we commit it. Then the line everybody writes: &lt;code&gt;bg.add_task(convert, row.id)&lt;/code&gt;. And we return straight away.&lt;/p&gt;

&lt;p&gt;The user gets an answer in about forty milliseconds. The conversion carries on afterwards. It really does look like it solved the problem.&lt;/p&gt;

&lt;p&gt;Now look again — not at what the code does, but at &lt;strong&gt;where it runs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;BackgroundTask&lt;/code&gt; has no worker. The slow work is running &lt;strong&gt;inside the API server&lt;/strong&gt;, the one box whose whole job is to answer fast. There is no arrow to the worker, because there is no worker.&lt;/p&gt;

&lt;p&gt;And now three ordinary things end the story badly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The process runs out of memory, and Linux kills it.&lt;/li&gt;
&lt;li&gt;You deploy, and the old process is replaced in the middle of a file.&lt;/li&gt;
&lt;li&gt;The machine simply reboots.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In all three cases the work is gone. What is left behind is a row that still says &lt;code&gt;converting&lt;/code&gt;, forever, with nothing anywhere knowing that a job was lost.&lt;/p&gt;

&lt;p&gt;The work lived inside a process whose whole purpose is to be replaceable.&lt;/p&gt;




&lt;h2&gt;
  
  
  A job is a row
&lt;/h2&gt;

&lt;p&gt;Here is the sentence everything else follows from. &lt;strong&gt;A job is a row. Not a function call.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A function call lives inside one process. When that process ends, the call is gone, and there is nothing left to look at.&lt;/p&gt;

&lt;p&gt;A row lives in the database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It &lt;strong&gt;outlives the request&lt;/strong&gt; that created it.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;outlives the worker&lt;/strong&gt;, because if that worker dies, another one can pick the same row up again.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;outlives your deploy&lt;/strong&gt;, because a deploy replaces processes. It does not touch rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the rule is simple. Write the work down first. Only then do it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The table is the whole queue
&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;jobs&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;bigserial&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;kind&lt;/span&gt;       &lt;span class="nb"&gt;text&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;payload&lt;/span&gt;    &lt;span class="n"&gt;jsonb&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;status&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempts&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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;run_after&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;locked_by&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;heartbeat&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;jobs_ready&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the columns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kind&lt;/code&gt; says what work this is.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;payload&lt;/code&gt; is JSON, and carries the arguments.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt; starts at &lt;code&gt;queued&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attempts&lt;/code&gt; lets you stop a job that keeps failing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_after&lt;/code&gt; is a timestamp, so a job can be scheduled for later — and so a retry can wait before trying again.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;locked_by&lt;/code&gt; says which worker owns this row right now.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;heartbeat&lt;/code&gt; says when that worker last proved it was still alive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then one index. Notice the word &lt;code&gt;WHERE&lt;/code&gt; at the end. That is a &lt;strong&gt;partial index&lt;/strong&gt;: it only covers the rows that are still &lt;code&gt;queued&lt;/code&gt;. That matters, because a jobs table fills up with finished rows very quickly, and you do not want the lookup to get slower every day.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one thing a broker structurally cannot do
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;broker&lt;/strong&gt; is a separate server whose only job is to hold a list of work — RabbitMQ, or Celery with Redis behind it. We are not using one. Look at what that buys you.&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;create_upload&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="n"&gt;Session&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="nb"&gt;str&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&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;begin&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;                 &lt;span class="c1"&gt;# ONE transaction opens here
&lt;/span&gt;        &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&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;converting&lt;/span&gt;&lt;span class="sh"&gt;"&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;add&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                   &lt;span class="c1"&gt;# the database gives us row.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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;convert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;payload&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;upload_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;row&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="c1"&gt;# the commit happened on that line. both rows, or neither.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One transaction. We insert the upload row, we flush so the database gives us the id, then we insert the job row using that id. The commit happens when the block ends. &lt;strong&gt;Both rows, or neither.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now think about what happens with a broker instead. You have two systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The database can commit while the message to the broker is lost. Now you have an upload nobody will ever convert.&lt;/li&gt;
&lt;li&gt;Or the message can be sent while the database rolls back. Now a worker picks up a job for a row that does not exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of those are real bugs that real teams spend real weeks on. Put the job in the same table and the same transaction, and neither one can happen. It is not that they are unlikely. They are &lt;strong&gt;impossible&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Thirty workers, one table
&lt;/h2&gt;

&lt;p&gt;How do thirty workers not collide? With one keyword.&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="c1"&gt;-- app/worker/claim.py — handed to SQLAlchemy text()&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locked_by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_after&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&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;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the sub-query from the bottom up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LIMIT 1&lt;/code&gt; — take a single row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FOR UPDATE&lt;/code&gt; — lock that row, so no other worker can take it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SKIP LOCKED&lt;/code&gt; — and this is the important part. Without it, a worker that finds a locked row &lt;strong&gt;waits&lt;/strong&gt; for it. With it, the worker steps &lt;strong&gt;over&lt;/strong&gt; that row and takes the next free one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran this with two workers starting at the same moment on a real Postgres:&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;Worker A took&lt;/th&gt;
&lt;th&gt;Worker B took&lt;/th&gt;
&lt;th&gt;Worker B waited&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ids 1, 2&lt;/td&gt;
&lt;td&gt;ids 3, 4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.12 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FOR UPDATE&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;ids 1, 2&lt;/td&gt;
&lt;td&gt;blocked until A committed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.035 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With &lt;code&gt;SKIP LOCKED&lt;/code&gt;, worker B stepped straight over A's two rows and was working in about one millisecond.&lt;/p&gt;

&lt;p&gt;Without it, worker B blocked for 3.035 seconds — exactly how long worker A held its transaction. While it waited, &lt;code&gt;pg_stat_activity&lt;/code&gt; showed it as &lt;code&gt;wait_event_type = 'Lock'&lt;/code&gt;, &lt;code&gt;wait_event = 'transactionid'&lt;/code&gt;. It was not doing anything useful. It was queueing behind a row it was never going to get, because once A committed &lt;code&gt;status = 'running'&lt;/code&gt;, READ COMMITTED re-checked the &lt;code&gt;WHERE&lt;/code&gt; clause and those rows no longer matched.&lt;/p&gt;

&lt;p&gt;In production that hold is not three seconds. It is the length of the job.&lt;/p&gt;




&lt;h2&gt;
  
  
  So do you even need a broker?
&lt;/h2&gt;

&lt;p&gt;People argue about this with the wrong measurement. They ask how long the job runs. That is not the axis.&lt;/p&gt;

&lt;p&gt;The real question is: &lt;strong&gt;who owns the status of this job?&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;Put it in a table when…&lt;/th&gt;
&lt;th&gt;Reach for a broker when…&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;A user can ask "is my file ready yet?"&lt;/td&gt;
&lt;td&gt;Nobody ever asks about one individual job&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;You need to show progress, or the reason something failed&lt;/td&gt;
&lt;td&gt;You are sending one event out to many consumers&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;The job must survive the queue itself restarting&lt;/td&gt;
&lt;td&gt;The volume is high and each message is tiny&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Volume is thousands a day, not millions a minute&lt;/td&gt;
&lt;td&gt;Fan-out and routing are the actual product&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the left column, the status is &lt;strong&gt;domain data&lt;/strong&gt;. It belongs in a table you can query, join and index.&lt;/p&gt;

&lt;p&gt;Both are correct answers — to different questions. And when the table genuinely runs out of room, the next step is usually not a broker. It is &lt;strong&gt;PgBouncer in transaction mode&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trap: a connection is not a transaction
&lt;/h2&gt;

&lt;p&gt;Now the reason this post exists.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;connection&lt;/strong&gt; is the seat at the counter. Your worker holds one so it can talk to the database at all. A &lt;strong&gt;transaction&lt;/strong&gt; is the page lying open on that counter, not signed off yet.&lt;/p&gt;

&lt;p&gt;Here is the problem. Your worker takes both, at the same moment, without anybody asking it to. One of them costs a seat. The other one costs the whole building.&lt;/p&gt;

&lt;p&gt;Take them one at a time. Expensive one first.&lt;/p&gt;




&lt;h3&gt;
  
  
  Child one: the invisible one
&lt;/h3&gt;

&lt;p&gt;Here is what people usually say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A connection that is &lt;code&gt;idle in transaction&lt;/code&gt; blocks VACUUM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I measured it. &lt;strong&gt;That sentence is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VACUUM&lt;/code&gt; is the cleanup job inside Postgres. When you update a row, the old version is left behind as a &lt;strong&gt;dead tuple&lt;/strong&gt;, and VACUUM is what reclaims that space. It can only reclaim a dead version that no open transaction could still need to see. The oldest thing any open transaction might still need is called the &lt;strong&gt;xmin horizon&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the question is not "is a transaction open?" It is "&lt;strong&gt;is this transaction holding the horizon back?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;I built a table with 200,000 dead tuples, opened one blocking session in nine different states, and ran &lt;code&gt;VACUUM (VERBOSE)&lt;/code&gt; on a table that blocker had never touched. Here is what actually happened:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;The blocking session&lt;/th&gt;
&lt;th&gt;Holds a snapshot / XID?&lt;/th&gt;
&lt;th&gt;Dead tuples reclaimed?&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Control — no long transaction open&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;✅ reclaimed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;READ COMMITTED, bare &lt;code&gt;BEGIN;&lt;/code&gt;, no statement&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;✅ reclaimed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;READ COMMITTED, &lt;code&gt;SELECT&lt;/code&gt; only, then idle&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;reclaimed&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;READ COMMITTED, &lt;code&gt;UPDATE&lt;/code&gt;, then idle &lt;em&gt;(your worker)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;XID&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ nothing freed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;READ COMMITTED, statement still in flight&lt;/td&gt;
&lt;td&gt;snapshot&lt;/td&gt;
&lt;td&gt;❌ nothing freed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;READ COMMITTED, open cursor, then idle&lt;/td&gt;
&lt;td&gt;snapshot&lt;/td&gt;
&lt;td&gt;❌ nothing freed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;REPEATABLE READ, &lt;code&gt;SELECT&lt;/code&gt; only, then idle&lt;/td&gt;
&lt;td&gt;snapshot&lt;/td&gt;
&lt;td&gt;❌ nothing freed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;SERIALIZABLE, &lt;code&gt;SELECT&lt;/code&gt; only, then idle&lt;/td&gt;
&lt;td&gt;snapshot&lt;/td&gt;
&lt;td&gt;❌ nothing freed&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;REPEATABLE READ, but in a &lt;strong&gt;different database&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;snapshot&lt;/td&gt;
&lt;td&gt;✅ reclaimed&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read row three again. A READ COMMITTED session that only &lt;strong&gt;read&lt;/strong&gt; something and then sat idle for twenty seconds had all 200,000 dead tuples reclaimed normally. The folklore version of the rule is simply not true.&lt;/p&gt;

&lt;p&gt;Two things actually pin the horizon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Holding a &lt;strong&gt;snapshot&lt;/strong&gt; — that means REPEATABLE READ, SERIALIZABLE, an open cursor, or being in the middle of a statement.&lt;/li&gt;
&lt;li&gt;Holding an &lt;strong&gt;XID&lt;/strong&gt; — a transaction id, which Postgres hands out the moment a transaction &lt;strong&gt;writes anything&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now look at what your worker did to claim its job. It ran &lt;code&gt;UPDATE jobs SET status = 'running'&lt;/code&gt;. That is a write. So it holds a transaction id for the entire length of the conversion.&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="c1"&gt;-- the worker: it claimed a job, and the claim was a WRITE.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&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;-- ... and now it converts a file for forty minutes ...&lt;/span&gt;

&lt;span class="c1"&gt;-- meanwhile, on a table this worker has never touched:&lt;/span&gt;
&lt;span class="k"&gt;VACUUM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- tuples: 0 removed, 400000 remain, 200000 are dead&lt;/span&gt;
&lt;span class="c1"&gt;--         but not yet removable&lt;/span&gt;
&lt;span class="c1"&gt;-- removable cutoff: 783  &amp;lt;-- the transaction id of that worker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output is copied from the real run. &lt;code&gt;removable cutoff: 783&lt;/code&gt; was exactly that worker's &lt;code&gt;backend_xid&lt;/code&gt;. Nothing was freed — on a table it had never opened.&lt;/p&gt;

&lt;p&gt;Two limits are worth being precise about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is &lt;strong&gt;database-wide&lt;/strong&gt;, not cluster-wide. The last row of the table shows it: the same blocker sitting in a &lt;em&gt;different&lt;/em&gt; database did not hold this one back.&lt;/li&gt;
&lt;li&gt;The damage stops the moment the transaction commits. The horizon is released immediately; it is not a permanent debt.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Child two: connections multiply
&lt;/h3&gt;

&lt;p&gt;This one is cheaper on its own. It multiplies.&lt;/p&gt;

&lt;p&gt;Count the connections one busy worker holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one for the loop that claims jobs,&lt;/li&gt;
&lt;li&gt;one for the heartbeat that says it is alive,&lt;/li&gt;
&lt;li&gt;one inside the handler that is doing the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three. Thirty workers, doing nothing clever, on a completely ordinary day: &lt;strong&gt;ninety&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And how many can you have? A stock Postgres sets &lt;code&gt;max_connections = 100&lt;/code&gt; and keeps &lt;code&gt;superuser_reserved_connections = 3&lt;/code&gt; back for the administrator. So an ordinary application role gets &lt;strong&gt;97&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your API server has a &lt;strong&gt;connection pool&lt;/strong&gt; of its own on top of that. A pool is a small set of connections opened once and reused. SQLAlchemy's defaults are &lt;code&gt;pool_size=5&lt;/code&gt; and &lt;code&gt;max_overflow=10&lt;/code&gt; — that is &lt;strong&gt;15 per process&lt;/strong&gt;, so two API processes can want thirty.&lt;/p&gt;

&lt;p&gt;Ninety plus thirty is one hundred and twenty. You have ninety-seven. Somebody is refused.&lt;/p&gt;

&lt;p&gt;Nothing in that arithmetic is a bug or a leak. That is the default configuration, running exactly as designed.&lt;/p&gt;

&lt;p&gt;And here is a detail almost nobody expects. The error your application sees is &lt;strong&gt;not&lt;/strong&gt; the famous one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  remaining connection slots are reserved for roles
        with the SUPERUSER attribute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sorry, too many clients already&lt;/code&gt; is what a &lt;strong&gt;superuser&lt;/strong&gt; sees. An ordinary application role hits the message above, three connections earlier.&lt;/p&gt;

&lt;p&gt;The box that hears it first is the API server — because the workers already took their seats and are sitting on them, while the API server is still asking for new ones. The workers broke nothing. They just arrived first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two problems, one query
&lt;/h2&gt;

&lt;p&gt;And the obvious version of that query is wrong.&lt;/p&gt;

&lt;p&gt;Most people look for sessions where &lt;code&gt;backend_xmin IS NOT NULL&lt;/code&gt;. That misses your worker completely, because a READ COMMITTED transaction that wrote something sets a &lt;code&gt;backend_xid&lt;/code&gt; and leaves &lt;code&gt;backend_xmin&lt;/code&gt; empty.&lt;/p&gt;

&lt;p&gt;Combine the two columns and you catch every case:&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="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;holding_back_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;open_for&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;holding_back_by&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the results like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A session that only read, then went idle: no transaction id, nothing held back. Harmless.&lt;/li&gt;
&lt;li&gt;A session that wrote a row and then went idle — exactly your worker: it has a transaction id, and it pins the cleanup.&lt;/li&gt;
&lt;li&gt;A session using REPEATABLE READ, or holding an open cursor: it holds a snapshot, so it pins the cleanup too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One query, and it tells you which of the two problems you actually have.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix is a shape, not a setting
&lt;/h2&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;run_one&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="c1"&gt;# session 1 — the claim
&lt;/span&gt;        &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# short. milliseconds.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="ow"&gt;is&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="bp"&gt;False&lt;/span&gt;
        &lt;span class="n"&gt;job_id&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;              &lt;span class="c1"&gt;# snapshot the fields we need
&lt;/span&gt;        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# BEFORE the session goes away
&lt;/span&gt;
    &lt;span class="c1"&gt;# ---- nothing held here: no session, no connection, no transaction ----
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# minutes. hours. it does not matter.
&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="c1"&gt;# session 2 — record the outcome
&lt;/span&gt;        &lt;span class="n"&gt;s&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="n"&gt;FINISH&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;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;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Session one: claim the job.&lt;/strong&gt; This part is short — a few milliseconds — and it commits and closes immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then, the important line: nothing is held here.&lt;/strong&gt; No session. No connection. No transaction. The conversion can take five minutes or five hours, and the database does not care, because the database does not know it is happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session two: write the result, and close.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the entire pattern. Claim fast, close, work holding nothing, then record.&lt;/p&gt;

&lt;p&gt;And copying &lt;code&gt;job.id&lt;/code&gt; and &lt;code&gt;job.payload&lt;/code&gt; out before the block ends is not tidiness. SQLAlchemy's &lt;code&gt;Session&lt;/code&gt; runs with &lt;code&gt;expire_on_commit=True&lt;/code&gt; by default, which marks every attribute on that object as out of date when the transaction commits. Read &lt;code&gt;job.payload&lt;/code&gt; after the block and you get a &lt;code&gt;DetachedInstanceError&lt;/code&gt; — there is no session left to load it from. (Note the precise cause: it is &lt;code&gt;expire_on_commit&lt;/code&gt;, not &lt;code&gt;close()&lt;/code&gt; on its own. A session closed &lt;strong&gt;without&lt;/strong&gt; committing leaves already-loaded attributes readable.)&lt;/p&gt;




&lt;h2&gt;
  
  
  One setting left
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pool_size&lt;/span&gt;&lt;span class="o"&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;# the default. PER PROCESS.
&lt;/span&gt;    &lt;span class="n"&gt;max_overflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# the default. PER PROCESS.
&lt;/span&gt;    &lt;span class="n"&gt;pool_pre_ping&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="c1"&gt;# NOT the default. turn it on.
&lt;/span&gt;    &lt;span class="n"&gt;pool_recycle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# and retire a connection after 30 minutes
&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;pool_size=5&lt;/code&gt; and &lt;code&gt;max_overflow=10&lt;/code&gt; are the defaults, and both are &lt;strong&gt;per process&lt;/strong&gt;. &lt;code&gt;pool_recycle=1800&lt;/code&gt; retires a connection after thirty minutes. And &lt;code&gt;pool_pre_ping&lt;/code&gt; is &lt;strong&gt;off&lt;/strong&gt; by default. Turn it on.&lt;/p&gt;

&lt;p&gt;Here is why it is correctness, not tidiness.&lt;/p&gt;

&lt;p&gt;A pooled connection can be closed underneath you while it sits idle — by a network device, by a database restart, by a timeout. Without pre-ping, the next time your code uses it you get a &lt;code&gt;sqlalchemy.exc.OperationalError&lt;/code&gt; (SQLAlchemy error code &lt;code&gt;e3q8&lt;/code&gt;, wrapping a &lt;code&gt;psycopg2.OperationalError&lt;/code&gt;). It is &lt;strong&gt;not&lt;/strong&gt; an &lt;code&gt;InterfaceError&lt;/code&gt;, which is what most people write their &lt;code&gt;except&lt;/code&gt; clause against.&lt;/p&gt;

&lt;p&gt;Now think about &lt;em&gt;when&lt;/em&gt; that happens. Your worker has just finished a forty-minute conversion and is about to write down that it succeeded. The write fails. The job is recorded as failed, and the sweeper runs the whole thing again.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;pool_pre_ping=True&lt;/code&gt;, the pool quietly tests the connection on checkout, throws it away, and opens a fresh one. I measured it: the backend process id simply changes, and your code never notices.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, all of it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Because a job is a row&lt;/strong&gt;, it survives the request, the worker and the deploy. Many workers share it with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;. It commits together with the business row, so it can never be lost or invented. And a user can simply ask the table whether it is ready. You get a queue, and you did not add a service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because a connection is not a transaction&lt;/strong&gt;, a worker that wrote something and then went idle stops the cleanup everywhere in that database. Connections are cheap one by one and expensive together. Use &lt;code&gt;COALESCE(backend_xmin, backend_xid)&lt;/code&gt; to find them.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;claim, close, do the work holding nothing, then record.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two settings are worth turning on today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pool_pre_ping=True&lt;/code&gt; on the engine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; on Postgres, which kills a session that has been sitting on an open transaction for too long.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if that joined a few things up — go and look at &lt;code&gt;pg_stat_activity&lt;/code&gt; on your own database right now.&lt;/p&gt;

&lt;p&gt;The full walk-through, with every measurement on screen, is the &lt;a href="https://youtu.be/W2gGx9ABv4s" rel="noopener noreferrer"&gt;15-minute episode&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>backend</category>
    </item>
    <item>
      <title>Heaps and Priority Queues: The Array That Is Secretly a Tree</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:10:37 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/heaps-and-priority-queues-the-array-that-is-secretly-a-tree-2lbo</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/heaps-and-priority-queues-the-array-that-is-secretly-a-tree-2lbo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/xu7lnIBCKNg" 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/heaps-and-priority-queues-the-array-that-is-secretly-a-tree?id=157" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One warning before we start, because the word is overloaded. &lt;strong&gt;This is the heap &lt;em&gt;data structure&lt;/em&gt;, not the heap *memory region&lt;/strong&gt;* where your language allocates new objects. They share a name and nothing else — different concept, different field of study, zero overlap. If you came looking for the memory one (stack vs heap allocation), this is not that post, and it never touches it again.&lt;/p&gt;

&lt;p&gt;The data structure does exactly one job: &lt;strong&gt;it always hands you the most urgent item next&lt;/strong&gt;, and it does that cheaply.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this bites in real code
&lt;/h2&gt;

&lt;p&gt;You run background jobs — nightly reports, welcome emails, invoice files, password resets. For a long time you process them in arrival order, and that is fine.&lt;/p&gt;

&lt;p&gt;Then the queue gets long. One night there are &lt;strong&gt;100,000 jobs waiting, and 12,000 of them are nightly reports&lt;/strong&gt;. A password reset lands at the back. Someone is sitting there, staring at a "check your email" screen, behind twelve thousand PDFs.&lt;/p&gt;

&lt;p&gt;The fix sounds trivial: stop taking the &lt;em&gt;oldest&lt;/em&gt; job, take the &lt;em&gt;most urgent&lt;/em&gt; one. That single change is the whole problem, because "find the smallest thing in a big pile, over and over" is startlingly expensive done naively.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two obvious implementations
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# jobs.py  —  100,000 queued jobs. always take the most urgent one next.
&lt;/span&gt;
&lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;                          &lt;span class="c1"&gt;# every job is a pair: (urgency, name)
&lt;/span&gt;
&lt;span class="c1"&gt;# way 1 — a plain list. cheap to add. expensive to choose.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;take_most_urgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;):&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# look at all 100,000 to find one
&lt;/span&gt;    &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&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;# then look at them all again to delete it
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;                    &lt;span class="c1"&gt;# 72.0 seconds to drain the queue
&lt;/span&gt;
&lt;span class="c1"&gt;# way 2 — keep it sorted by -urgency, so the most urgent one is LAST.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bisect&lt;/span&gt;
&lt;span class="k"&gt;def&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;jobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urgency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;bisect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;urgency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the search is fast. making room is not:
&lt;/span&gt;                                   &lt;span class="c1"&gt;# every item after that spot shifts by one.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;take_sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;             &lt;span class="c1"&gt;# free to take. 1.39 seconds for the queue.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Way 1 makes &lt;strong&gt;two full passes over the list for every single job&lt;/strong&gt; you pull. Way 2 moves the cost to insertion: &lt;code&gt;bisect&lt;/code&gt; finds the right slot in log n, but then every item after that slot shifts over by one, which is O(n) of memory movement.&lt;/p&gt;

&lt;p&gt;Measured on one machine, CPython 3.10, draining all 100,000 jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;Time to drain 100,000 jobs&lt;/th&gt;
&lt;th&gt;Cost per operation&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Plain list + &lt;code&gt;min()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;O(n) to choose, O(n) to remove&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Sorted list (&lt;code&gt;bisect.insort&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.39 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;O(n) to insert, O(1) to take&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Binary heap&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54.8 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;O(log n) both ways&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seventy-two seconds. Not milliseconds — seconds, to work through a queue of background jobs. The sorted list is a real improvement and still spends &lt;strong&gt;25x&lt;/strong&gt; what the heap does. The heap is roughly &lt;strong&gt;1,300x&lt;/strong&gt; faster than the naive version.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bakery and the hospital
&lt;/h2&gt;

&lt;p&gt;Before any more code, one picture, because there is a confusion worth killing early.&lt;/p&gt;

&lt;p&gt;On the left, a &lt;strong&gt;bakery&lt;/strong&gt;. You walk in, take a numbered ticket, and the counter calls the numbers in order. That is a plain FIFO queue. It never asks &lt;em&gt;why&lt;/em&gt; you came. If you are in real trouble, you still wait behind everybody else.&lt;/p&gt;

&lt;p&gt;On the right, a &lt;strong&gt;hospital&lt;/strong&gt;. The same five people walk in, in the same order. But a nurse at the desk asks one question each and reorders them on the spot. Arrival order stops mattering; urgency decides.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;priority queue&lt;/strong&gt; is that promise: items keep arriving, and whenever you ask, you get the best one out. It is a &lt;em&gt;behaviour&lt;/em&gt;, not an implementation. A &lt;strong&gt;binary heap&lt;/strong&gt; is the standard, cheap way to keep that promise — and it has only two moves in the entire structure: &lt;strong&gt;sift up&lt;/strong&gt;, which runs when you add, and &lt;strong&gt;sift down&lt;/strong&gt;, which runs when you take.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one rule
&lt;/h2&gt;

&lt;p&gt;Draw your items as a tree. Every circle holds one value. Now the rule, and there is only one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every parent must be smaller than or equal to both of its children.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is it. That is a heap.&lt;/p&gt;

&lt;p&gt;Read the rule again and notice what it does &lt;em&gt;not&lt;/em&gt; say. It says &lt;strong&gt;nothing about two nodes sitting next to each other&lt;/strong&gt;. &lt;code&gt;[3, 5, 7, 9, 12, 8, 14]&lt;/code&gt; is a perfectly legal min-heap even though &lt;code&gt;12&lt;/code&gt; sits before &lt;code&gt;8&lt;/code&gt; — they are in different subtrees and the rule never compares them.&lt;/p&gt;

&lt;p&gt;This is the single most important thing to internalise: &lt;strong&gt;a heap is not sorted&lt;/strong&gt;. &lt;code&gt;sorted([3, 5, 7, 9, 12, 8, 14])&lt;/code&gt; gives you &lt;code&gt;[3, 5, 7, 8, 9, 12, 14]&lt;/code&gt;, a different list. That weakness &lt;em&gt;is&lt;/em&gt; the reason it is fast. Sorting maintains a global ordering on every insert; a heap maintains a local one, only along each parent-child edge, and a local promise is enormously cheaper to keep.&lt;/p&gt;




&lt;h2&gt;
  
  
  There is no tree
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes it cheap, and it is smaller than most people expect.&lt;/p&gt;

&lt;p&gt;There are no circles. There are no node objects. &lt;strong&gt;There is a plain list.&lt;/strong&gt; Take the tree, read it level by level — top to bottom, left to right — and write those values into one flat array in that order. That array &lt;em&gt;is&lt;/em&gt; the heap. The tree was only ever a picture we drew to explain it.&lt;/p&gt;

&lt;p&gt;Because the layout is fixed, family relationships become arithmetic:&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;# heap.py  —  the whole trick: the tree is never built. it is computed.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parent&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="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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="mi"&gt;2&lt;/span&gt;      &lt;span class="c1"&gt;# who is above me
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;left&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;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&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;# my first child
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;right&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;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&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;# my second child
&lt;/span&gt;
&lt;span class="c1"&gt;# there is no Node class here. no left pointer, no right pointer, no
# allocation per item. the tree is only a picture we draw of a flat list.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_heap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                      &lt;span class="c1"&gt;# the one rule the list must obey
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;parent&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="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;heap&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;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="c1"&gt;# $ python -i heap.py
# &amp;gt;&amp;gt;&amp;gt; is_heap([3, 5, 7, 9, 12, 8, 14])
# True                                  &amp;lt;- a legal heap
# &amp;gt;&amp;gt;&amp;gt; sorted([3, 5, 7, 9, 12, 8, 14])
# [3, 5, 7, 8, 9, 12, 14]               &amp;lt;- a different list. a heap is NOT sorted.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three one-line functions replace an entire node class. To find who sits above index 7, you do not chase a pointer — you compute &lt;code&gt;(7 - 1) // 2 = 3&lt;/code&gt;. No allocation per item, no pointer chasing, and the whole structure sits in one contiguous block of memory that the CPU cache actually likes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Move one: push, and sift up
&lt;/h2&gt;

&lt;p&gt;You add a job with urgency &lt;code&gt;2&lt;/code&gt; — very urgent. It lands on the end of the list, at index 7, which is almost certainly the wrong place. So you fix it, one step at a time.&lt;/p&gt;

&lt;p&gt;The parent of index 7 is &lt;code&gt;(7-1)//2 = 3&lt;/code&gt;, which holds &lt;code&gt;9&lt;/code&gt;. Is &lt;code&gt;2 &amp;lt; 9&lt;/code&gt;? Yes — trade places. Now your value sits at index 3, and you repeat against &lt;em&gt;its&lt;/em&gt; parent. You climb until the rule holds again.&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;# heap.py  —  push: drop it on the end, then walk it up
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sift_up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&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;while&lt;/span&gt; &lt;span class="n"&gt;i&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;heap&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;parent&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="n"&gt;heap&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="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;parent&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;parent&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="n"&gt;heap&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="c1"&gt;# trade places
&lt;/span&gt;        &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parent&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="c1"&gt;# and keep climbing
&lt;/span&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;heap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;heap&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;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# the list just grows on the end
&lt;/span&gt;    &lt;span class="nf"&gt;sift_up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&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="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# at most one swap per level
&lt;/span&gt;
&lt;span class="c1"&gt;# the loop stops for one of two reasons: you reached index 0, or your
# parent is already smaller than you. both mean the rule holds again.
&lt;/span&gt;
&lt;span class="c1"&gt;# $ python heap.py
#   before        [3, 5, 7, 9, 12, 8, 14]
#   push(2)       [2, 3, 7, 5, 12, 8, 14, 9]      &amp;lt;- 3 swaps, 8 items
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop stops for exactly one of two reasons: you reached index 0 (you are the smallest thing in the heap), or your parent is already smaller than you (the rule holds again everywhere above).&lt;/p&gt;




&lt;h2&gt;
  
  
  Move two: pop, and sift down
&lt;/h2&gt;

&lt;p&gt;Taking the most urgent job out is the mirror image. &lt;strong&gt;The answer itself is free&lt;/strong&gt; — it is always at index 0.&lt;/p&gt;

&lt;p&gt;The work is closing the hole. A heap has to stay a &lt;em&gt;complete&lt;/em&gt; tree with no gaps, so you take the very last item in the list and drop it into the vacated root. It is almost certainly too big to be there, so you sink it: compare against both children, swap with the smaller of them, and repeat.&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;# heap.py  —  pop: the answer is index 0. the work is closing the hole.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sift_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&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="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;)&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;smallest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;left&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="nf"&gt;right&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;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&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;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;heap&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# nobody below me is smaller: stop
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;heap&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="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;heap&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smallest&lt;/span&gt;                    &lt;span class="c1"&gt;# follow the value down
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                   &lt;span class="c1"&gt;# take the last item off the end
&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;heap&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;last&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heap&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="c1"&gt;# this is the answer we owe the caller
&lt;/span&gt;    &lt;span class="n"&gt;heap&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;                      &lt;span class="c1"&gt;# drop the last item into the hole
&lt;/span&gt;    &lt;span class="nf"&gt;sift_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&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="c1"&gt;# and sink it to where it belongs
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two length checks are not decoration. A node near the bottom may have one child or none, and reading &lt;code&gt;heap[left(i)]&lt;/code&gt; without checking is an &lt;code&gt;IndexError&lt;/code&gt; waiting for your production queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why both moves are cheap
&lt;/h3&gt;

&lt;p&gt;Both moves walk &lt;strong&gt;one level at a time&lt;/strong&gt;, so the obvious question is: how many levels are there?&lt;/p&gt;

&lt;p&gt;A complete binary tree doubles at every level — 1 node, then 2, then 4, then 8. The number of levels is therefore log₂(n): how many times you can halve the count before reaching one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000,000 items → &lt;strong&gt;19 levels&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;1,000,000,000 items → &lt;strong&gt;29 levels&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A thousand-fold increase in data costs you ten extra comparisons. That is the whole performance story.&lt;/p&gt;




&lt;h2&gt;
  
  
  The move that surprises people: heapify
&lt;/h2&gt;

&lt;p&gt;Say you &lt;em&gt;already&lt;/em&gt; have a list of a million jobs and you want a heap out of it. The obvious answer is to push them in one at a time. That is the wrong answer.&lt;/p&gt;

&lt;p&gt;Instead, start at the last node that actually has a child — halfway through the list — and walk &lt;strong&gt;backwards&lt;/strong&gt; toward the root, sifting each node down.&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;# heap.py  —  you already HAVE the list. do not push it in one item at a time.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;heapify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;heap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&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;# the last node that HAS a child
&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="n"&gt;start&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="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="p"&gt;):&lt;/span&gt;      &lt;span class="c1"&gt;# walk backwards, towards the root
&lt;/span&gt;        &lt;span class="nf"&gt;sift_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap&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="c1"&gt;# everything below i is already legal
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;heap&lt;/span&gt;

&lt;span class="c1"&gt;# the second half of any list is all leaves. a leaf has no children, so it
# is already a legal heap of one item. that is why the loop skips them.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is this allowed? Because &lt;strong&gt;the second half of any list is all leaves&lt;/strong&gt;, and a leaf has no children, so it is already a legal heap of one item. Half the work is free before you start.&lt;/p&gt;

&lt;p&gt;Measured, building a heap from 1,000,000 items on worst-case input:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;Build strategy&lt;/th&gt;
&lt;th&gt;Swaps&lt;/th&gt;
&lt;th&gt;Swaps per item&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Bottom-up &lt;code&gt;heapify&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;999,988&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;21.0 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Pushing one at a time&lt;/td&gt;
&lt;td&gt;17,951,445&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17.95&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;322.7 ms&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One swap per item against eighteen — about &lt;strong&gt;15x&lt;/strong&gt; on the clock. The reason is a counting argument, and it is the source of the classic "heapify is O(n), not O(n log n)" result: when you push one at a time, &lt;em&gt;every&lt;/em&gt; item can climb the full height. When you build bottom-up, half the nodes are leaves and cannot sink at all, a quarter can sink one level, and only the single root can fall the full height. Sum nodes × distance across the tree and the series converges to n.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest part: what a heap will not do
&lt;/h2&gt;

&lt;p&gt;This is where people get hurt. A heap answers &lt;strong&gt;exactly one question&lt;/strong&gt;: what is the smallest thing you are holding. Everything else is a trap.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not a sorted list&lt;/strong&gt;, and you cannot read it in order without emptying it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finding a specific item by name means scanning the whole array&lt;/strong&gt; — precisely the O(n) cost you adopted a heap to avoid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no cheap delete-in-the-middle&lt;/strong&gt; and no cheap "change this job's priority" after it is queued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Even the second-smallest item is not simply at index 1.&lt;/strong&gt; It is at index 1 &lt;em&gt;or&lt;/em&gt; index 2, and you must check both. Across 20,000 random heaps, it sat at index 2 in &lt;strong&gt;8,074&lt;/strong&gt; of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need any of those things, you need something &lt;em&gt;beside&lt;/em&gt; the heap — typically a dictionary from job name to array position, maintained in step. Reaching for a heap and then searching through it is the mistake this section exists to prevent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three guards before this ships
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# scheduler.py  —  the three guards you need before this ships
&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;
&lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 1. TIES. two jobs at urgency 5, so python compares the JOBS themselves:
&lt;/span&gt;&lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                 &lt;span class="c1"&gt;# TypeError: Job vs Job
&lt;/span&gt;&lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# a counter never ties, so equal
&lt;/span&gt;                                             &lt;span class="c1"&gt;# urgency means first in, first out.
# 2. YOU WANT THE LARGEST. negate the key. do not write a second heap.
&lt;/span&gt;&lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pq&lt;/span&gt;&lt;span class="p"&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;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# 3. TOP 10 OF A STREAM you cannot hold in memory. keep exactly 10.
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;top&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="c1"&gt;# top[0] is the worst of the best
&lt;/span&gt;        &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heapreplace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# one pop and one push, in one pass
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ties crash.&lt;/strong&gt; You push &lt;code&gt;(urgency, job)&lt;/code&gt;. Two jobs at urgency 5 — Python compares the urgencies, finds them equal, then falls through to comparing the &lt;em&gt;job objects&lt;/em&gt;. If your job class defines no ordering, that is a &lt;code&gt;TypeError&lt;/code&gt; in production at 3 a.m. The fix is a monotonic counter in the middle: it can never tie, so equal urgency degrades gracefully to first-in-first-out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You often want the largest.&lt;/strong&gt; Negate the key on the way in and again on the way out. Do not maintain a second heap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top-K of an unbounded stream.&lt;/strong&gt; Keep a heap of exactly K. Streaming 5,000,000 values for the top 10: sorting everything took &lt;strong&gt;2,018 ms and held all 5M in memory&lt;/strong&gt;; the bounded heap took &lt;strong&gt;1,078 ms and held 10&lt;/strong&gt;. Twice as fast and half a million times smaller.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The AI angle: this is your inference scheduler
&lt;/h2&gt;

&lt;p&gt;If you serve models rather than web pages, you have not escaped this structure — you have just met it under different names.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuous batching.&lt;/strong&gt; An LLM server holding hundreds of in-flight requests must decide, every iteration, which sequences join the next forward pass. That is a priority queue keyed on some mix of arrival time, sequence length and SLA class — and it is popped thousands of times per second, which is exactly the workload where 72 s versus 54.8 ms stops being academic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deadline and preemption tracking.&lt;/strong&gt; Which request breaches its time-to-first-token budget soonest? That is &lt;code&gt;heap[0]&lt;/code&gt;, in O(1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV-cache eviction.&lt;/strong&gt; When GPU memory fills, something must be evicted — least-recently-used, lowest-priority, or furthest-from-completion. "What dies next" is the canonical heap question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top-k sampling and beam search.&lt;/strong&gt; Selecting the k highest-probability tokens from a 100,000-entry vocabulary is a bounded-heap problem, guard 3 above, run once per generated token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector search.&lt;/strong&gt; Approximate-nearest-neighbour indexes (HNSW and friends) maintain candidate sets as bounded priority queues throughout the graph traversal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson generalises: &lt;strong&gt;when a system must repeatedly answer "which one next?" under pressure, a heap is nearly always underneath.&lt;/strong&gt; Recognising it is what lets you read a serving engine's scheduler and understand it in one pass.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where you have already used one
&lt;/h2&gt;

&lt;p&gt;You have almost certainly used a heap under a different name. Task schedulers use one to decide which job runs next. Timer systems use one to find the deadline expiring soonest. &lt;strong&gt;Dijkstra's algorithm and A*&lt;/strong&gt;, the shortest-path algorithms behind map routing and game pathfinding, are a priority queue with a graph attached.&lt;/p&gt;

&lt;p&gt;And here is where you will write one yourself: top-K over a stream, cache expiry, retry queues. Anything at all where the words are &lt;em&gt;"handle the most important one first."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Python calls it &lt;code&gt;heapq&lt;/code&gt;. Java calls it &lt;code&gt;PriorityQueue&lt;/code&gt;. Go has &lt;code&gt;container/heap&lt;/code&gt;. C++ has &lt;code&gt;priority_queue&lt;/code&gt;. Same structure, same two moves.&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;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;What is it?&lt;/td&gt;
&lt;td&gt;A flat array obeying one rule: every parent ≤ both children.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;What is it for?&lt;/td&gt;
&lt;td&gt;Repeatedly answering "what is the most urgent item?" in O(log n).&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Push / pop&lt;/td&gt;
&lt;td&gt;O(log n) — one swap per level, 19 levels at a million items.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Peek the minimum&lt;/td&gt;
&lt;td&gt;O(1) — it is index 0, always.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Build from an existing list&lt;/td&gt;
&lt;td&gt;O(n) bottom-up, &lt;strong&gt;not&lt;/strong&gt; O(n log n). 1.00 vs 17.95 swaps per item.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Find an arbitrary item&lt;/td&gt;
&lt;td&gt;O(n). Do not do this. Keep a side index.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Read everything in order&lt;/td&gt;
&lt;td&gt;O(n log n) and it destroys the heap. Use &lt;code&gt;sorted()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Reach for it when…&lt;/td&gt;
&lt;td&gt;"Handle the most important one first" — schedulers, timers, top-K, Dijkstra, cache expiry.&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Reach for something else when…&lt;/td&gt;
&lt;td&gt;You need ordering, lookup by key, mid-queue deletion, or priority updates.&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The rule of thumb:&lt;/strong&gt; if you catch yourself calling &lt;code&gt;min()&lt;/code&gt; inside a loop over a collection that keeps changing, you have hand-written the 72-second version. That is the moment to reach for &lt;code&gt;heapq&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three interview questions
&lt;/h2&gt;

&lt;p&gt;If this comes up in an interview, these are the three that get asked — and all three answers come from one picture: &lt;strong&gt;the tree above, and the same flat list below.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Why is a heap not just a sorted array?&lt;/strong&gt; Because a sorted array pays O(n) on every insert — everything after the new item shifts over. A heap only ever orders a parent against its own children, so it pays O(log n).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why is building a heap O(n) and not O(n log n)?&lt;/strong&gt; Because half the nodes are leaves and cannot sink at all, and only the root can fall the full height. Sum nodes × distance and the series converges to n.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you get the largest instead of the smallest?&lt;/strong&gt; Negate the key, or wrap items in a class with reversed comparison. Never keep a second heap in sync with the first.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Want the whole thing drawn, with sift up and sift down walked one swap at a time and every number above measured live? &lt;strong&gt;&lt;a href="https://youtu.be/xu7lnIBCKNg" rel="noopener noreferrer"&gt;Watch the full episode on YouTube&lt;/a&gt;&lt;/strong&gt; — or the &lt;a href="https://youtube.com/shorts/ILG1FQwqi4Q" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; if you just want the shape of it.&lt;/p&gt;

</description>
      <category>datastructures</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>python</category>
    </item>
    <item>
      <title>Arrays vs Linked Lists: Why the Textbook Winner Loses on Real Hardware</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Wed, 12 Aug 2026 06:16:49 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/arrays-vs-linked-lists-why-the-textbook-winner-loses-on-real-hardware-34fj</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/arrays-vs-linked-lists-why-the-textbook-winner-loses-on-real-hardware-34fj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/sEvygbNcq10" 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/arrays-vs-linked-lists-why-the-textbook-winner-loses-on-real-hardware?id=156" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every data-structures course teaches the same table. Array: insert O(n), because you have to shift everything. Linked list: insert O(1), because you just repoint two pointers. Conclusion: if you insert a lot, use a linked list.&lt;/p&gt;

&lt;p&gt;Then you write both, measure them, and the array wins anyway.&lt;/p&gt;

&lt;p&gt;Here is the measurement that starts the whole story. Ten million integers, held as one contiguous array and as ten million linked nodes. Walk each one, summing as you go — the &lt;em&gt;same&lt;/em&gt; operation count, the &lt;em&gt;same&lt;/em&gt; complexity class, O(n) both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;contiguous array: &lt;strong&gt;0.53 seconds&lt;/strong&gt; (52.6 ns per element)&lt;/li&gt;
&lt;li&gt;linked nodes: &lt;strong&gt;1.53 seconds&lt;/strong&gt; (153.3 ns per element)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2.9× apart, and nothing in the code explains it.&lt;/strong&gt; Both loops do one add per element. Big-O says they're identical. The machine disagrees, and the reason is not in the code at all — it's in &lt;em&gt;where the data physically sits&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Four ideas explain the whole gap: &lt;strong&gt;contiguity&lt;/strong&gt;, the &lt;strong&gt;64-byte cache line&lt;/strong&gt;, the &lt;strong&gt;prefetcher&lt;/strong&gt;, and &lt;strong&gt;pointer chasing&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The library, before the hardware
&lt;/h2&gt;

&lt;p&gt;Imagine you need to read a hundred books.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;array&lt;/strong&gt; version, all hundred sit in order on one shelf. You walk up once, and you can grab an armful at a time. Your arms hold eight books, so a hundred books is about thirteen trips.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;linked list&lt;/strong&gt; version, each book sits on its own stand, somewhere in the building. Inside each book is a slip of paper telling you where the &lt;em&gt;next&lt;/em&gt; one is. You cannot grab an armful, because you don't know where book two is until you've opened book one. A hundred books is a hundred separate walks — and you can't even start walking to the next stand until you've finished reading the current book.&lt;/p&gt;

&lt;p&gt;That's the entire performance story. The rest is just naming the hardware that plays the role of "arms" and "walk."&lt;/p&gt;




&lt;h2&gt;
  
  
  The two structures, in code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# layout.py — the same 10,000,000 numbers, held two ways
&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&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;10_000_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# ONE block: slot i sits beside slot i+1
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;__slots__&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;val&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;next&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 48 bytes, and no spare __dict__
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sum_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;              &lt;span class="c1"&gt;# walk the row
&lt;/span&gt;    &lt;span class="n"&gt;total&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;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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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="n"&gt;data&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;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sum_linked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;             &lt;span class="c1"&gt;# follow next
&lt;/span&gt;    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&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="n"&gt;head&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;node&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="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;next&lt;/span&gt;          &lt;span class="c1"&gt;# the next address lives INSIDE
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;                  &lt;span class="c1"&gt;# the node you just finished reading
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the last line of &lt;code&gt;sum_linked&lt;/code&gt;. &lt;strong&gt;The address of the next node is stored inside the node you are currently reading.&lt;/strong&gt; You cannot know where to go next until the current fetch has completed. That single property is what costs you 2.9×, and it has a name: &lt;strong&gt;pointer chasing&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the data actually sits
&lt;/h2&gt;

&lt;p&gt;The CPU never reads one integer from memory. It reads a &lt;strong&gt;cache line&lt;/strong&gt; — 64 bytes, always, minimum. That's the "armful."&lt;/p&gt;

&lt;p&gt;For a &lt;strong&gt;contiguous array&lt;/strong&gt; of 8-byte values, one 64-byte fetch brings back &lt;strong&gt;eight useful values&lt;/strong&gt;. You pay one trip to memory and get seven more elements for free. Walking a million elements costs you roughly 125,000 trips, not a million.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;scattered linked nodes&lt;/strong&gt;, each node is its own allocation, sitting wherever the allocator happened to put it. One 64-byte fetch brings back &lt;strong&gt;one useful value&lt;/strong&gt; — the rest of the line is that node's other fields and whatever unrelated bytes happen to be adjacent. Eight values cost eight full trips.&lt;/p&gt;

&lt;p&gt;And the price of a trip is not a rounding error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;follow one pointer, already in L1 cache: &lt;strong&gt;1.26 ns&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;follow one pointer, out in main memory: &lt;strong&gt;81.5 ns&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's &lt;strong&gt;65×&lt;/strong&gt;. Not the folklore "a cache miss costs ~100×" — measured on this machine, it's 65. (The only ~100×-shaped real number here is &lt;strong&gt;132×&lt;/strong&gt;, and that's random pointer chase versus &lt;em&gt;sequential streaming&lt;/em&gt;, which is a different comparison — keep that framing attached whenever you quote it.)&lt;/p&gt;

&lt;p&gt;Then there's the &lt;strong&gt;prefetcher&lt;/strong&gt;, which is the part most people never account for. When the CPU notices you walking memory in a predictable forward pattern, it starts fetching lines &lt;em&gt;before you ask for them&lt;/em&gt;. Walking a contiguous 1 GiB array in order costs &lt;strong&gt;0.62 ns per element&lt;/strong&gt; — faster than a single L1 pointer hop, because the memory traffic is happening in the background while you compute. The prefetcher cannot help a linked list at all: it can't guess an address that hasn't been loaded yet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;What the hardware does&lt;/th&gt;
&lt;th&gt;Contiguous array&lt;/th&gt;
&lt;th&gt;Scattered linked nodes&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Values per 64-byte fetch&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Can the prefetcher help?&lt;/td&gt;
&lt;td&gt;Yes — address is predictable&lt;/td&gt;
&lt;td&gt;No — next address is unknown until the current load lands&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Memory per 1,000,000 elements&lt;/td&gt;
&lt;td&gt;8.2 MB (&lt;code&gt;array.array&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;80 MB&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Walk 10,000,000, measured&lt;/td&gt;
&lt;td&gt;0.53 s&lt;/td&gt;
&lt;td&gt;1.53 s&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The proof: an experiment with no linked list in it
&lt;/h2&gt;

&lt;p&gt;Everything above is a &lt;em&gt;story&lt;/em&gt; about why the linked list is slower. It could be wrong. Maybe the gap is really about node objects, or attribute lookup, or allocation count — plenty of things differ between those two loops.&lt;/p&gt;

&lt;p&gt;So here's the experiment that isolates the cause. Take &lt;strong&gt;the same array&lt;/strong&gt;. Do &lt;strong&gt;the same additions&lt;/strong&gt;. Same class, same code, same object count. Change exactly one thing: the &lt;strong&gt;order&lt;/strong&gt; in which you touch the elements.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;walking in order: &lt;strong&gt;44.4 ns&lt;/strong&gt; per operation&lt;/li&gt;
&lt;li&gt;walking the identical data in shuffled order: &lt;strong&gt;265.8 ns&lt;/strong&gt; per operation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;~6× slower, with no linked list anywhere in the experiment.&lt;/strong&gt; Nothing changed but the access pattern. That's the cache effect, measured on its own, with every other variable held down.&lt;/p&gt;

&lt;p&gt;There's a matching version on the allocation side: keep the linked list, keep the class, keep the code, and shuffle only the &lt;em&gt;order the nodes were allocated in&lt;/em&gt;. That alone is &lt;strong&gt;2.1× slower&lt;/strong&gt; — pure layout, no algorithmic difference at all.&lt;/p&gt;

&lt;p&gt;This is the section to remember. The linked list isn't slow because it's a linked list. It's slow because &lt;strong&gt;it is the data structure most likely to scatter your data&lt;/strong&gt;, and scattered data defeats every memory optimization your CPU has.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Big-O counts, and what it never counts
&lt;/h2&gt;

&lt;p&gt;Big-O is not wrong here. It's answering a different question.&lt;/p&gt;

&lt;p&gt;Big-O counts &lt;strong&gt;operations&lt;/strong&gt;, and it deliberately throws away constant factors — that's the whole point of the abstraction, and it's why it survives across machines and decades. What it throws away includes: how far the data travelled, whether the fetch hit L1 or DRAM, and whether the prefetcher was able to work ahead.&lt;/p&gt;

&lt;p&gt;Two O(n) walks can be 2.9× apart. Two O(1) operations can be 65× apart. Big-O tells you how the cost &lt;strong&gt;grows&lt;/strong&gt;; it says nothing about what one unit of that cost actually &lt;strong&gt;is&lt;/strong&gt; on real silicon. For large asymptotic gaps — O(n) vs O(log n) — the growth term dominates and Big-O decides. For comparisons &lt;em&gt;within&lt;/em&gt; the same class, it's silent, and the memory hierarchy does the deciding.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest other half: where linked structures genuinely win
&lt;/h2&gt;

&lt;p&gt;Now the fair fight, because there is a real case here and it deserves real numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Front insertion.&lt;/strong&gt; Inserting at the head of an array means shifting every element. Inserting at the head of a linked list means writing one pointer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;array, N = 1,000,000: &lt;strong&gt;572,255 ns&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;linked, N = 1,000,000: &lt;strong&gt;243 ns&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a genuine, enormous, structural win. It is not a rounding error and it doesn't go away on better hardware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But that O(1) has a precondition that almost every textbook drops:&lt;/strong&gt; you must already &lt;em&gt;hold&lt;/em&gt; the node. Splicing after a node you have in hand is genuinely O(1). Finding that node first is not.&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;# insert_middle.py — the O(1) everyone quotes, with its precondition put back
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;splice_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;     &lt;span class="c1"&gt;# O(1) — TRUE, but only if you HOLD node
&lt;/span&gt;    &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;next&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;insert_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;     &lt;span class="c1"&gt;# what you actually have to write
&lt;/span&gt;    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&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="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;             &lt;span class="c1"&gt;# and this is not a memmove.
&lt;/span&gt;        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;next&lt;/span&gt;           &lt;span class="c1"&gt;# it is k dependent cache misses, one at a time.
&lt;/span&gt;    &lt;span class="nf"&gt;splice_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured at N = 1,000,000, inserting in the middle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list.insert(mid, x)&lt;/code&gt;: &lt;strong&gt;255,486 ns&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;linked: traverse + splice: &lt;strong&gt;63,488,013 ns&lt;/strong&gt; — &lt;strong&gt;249× slower&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The array's O(n) shift is a &lt;code&gt;memmove&lt;/code&gt;: one tight, prefetcher-friendly, sequential sweep that the hardware is exceptionally good at. The linked list's O(k) traversal is k &lt;em&gt;dependent&lt;/em&gt; cache misses in a row, each one waiting on the last. Same complexity class, wildly different machine.&lt;/p&gt;

&lt;p&gt;So the rule is not "linked lists are slow." It's: &lt;strong&gt;a linked structure wins when you already hold the position.&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;Operation, N = 1,000,000&lt;/th&gt;
&lt;th&gt;Array / list&lt;/th&gt;
&lt;th&gt;Linked&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Walk everything&lt;/td&gt;
&lt;td&gt;0.53 s&lt;/td&gt;
&lt;td&gt;1.53 s&lt;/td&gt;
&lt;td&gt;Array, 2.9×&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Insert at front&lt;/td&gt;
&lt;td&gt;572,255 ns&lt;/td&gt;
&lt;td&gt;243 ns&lt;/td&gt;
&lt;td&gt;Linked, huge&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Insert in middle, position not held&lt;/td&gt;
&lt;td&gt;255,486 ns&lt;/td&gt;
&lt;td&gt;63,488,013 ns&lt;/td&gt;
&lt;td&gt;Array, 249×&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deque.appendleft&lt;/code&gt; vs &lt;code&gt;list.insert(0,x)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;25,434 ns&lt;/td&gt;
&lt;td&gt;53.3 ns&lt;/td&gt;
&lt;td&gt;Linked, 477×&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Remove a node you already hold vs &lt;code&gt;del lst[i]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;13,692 ns&lt;/td&gt;
&lt;td&gt;335 ns&lt;/td&gt;
&lt;td&gt;Linked, 41×&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Linked structures also give you &lt;strong&gt;stable references&lt;/strong&gt;: a node's address doesn't move when its neighbours change, so pointers held elsewhere stay valid. Arrays give you no such guarantee. That property — not raw speed — is why linked structures show up inside allocator free lists, LRU caches, and intrusive kernel lists.&lt;/p&gt;




&lt;h2&gt;
  
  
  What your language already chose for you
&lt;/h2&gt;

&lt;p&gt;Most of this decision was made for you, and it's worth knowing what you actually have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Python &lt;code&gt;list&lt;/code&gt;&lt;/strong&gt; is a contiguous array — but of &lt;strong&gt;pointers&lt;/strong&gt;. The integers themselves are separate heap objects scattered elsewhere, which is why a million elements costs ~40 MB rather than 8.2 MB. You get contiguity of the &lt;em&gt;references&lt;/em&gt;, not of the values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;array.array('q')&lt;/code&gt;&lt;/strong&gt; is the true contiguous case: the 8-byte integers really do sit side by side. That's the 8.2 MB row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;collections.deque&lt;/code&gt;&lt;/strong&gt; is a doubly-linked list &lt;strong&gt;of blocks&lt;/strong&gt;, not of single elements. Each block holds many items contiguously, so it gets cache-friendly iteration &lt;em&gt;and&lt;/em&gt; O(1) ends. That hybrid is why &lt;code&gt;appendleft&lt;/code&gt; beats &lt;code&gt;list.insert(0, x)&lt;/code&gt; by 477×, and it's the shape most "linked list" wins in production actually take.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Java's &lt;code&gt;ArrayList&lt;/code&gt; vs &lt;code&gt;LinkedList&lt;/code&gt;&lt;/strong&gt;: the same story, and the reason &lt;code&gt;LinkedList&lt;/code&gt; is near-universally discouraged in modern Java style guides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NumPy arrays&lt;/strong&gt; are genuinely contiguous typed memory — the reason numeric Python is fast at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the pattern: the winning structures are &lt;strong&gt;blocked&lt;/strong&gt; — contiguous runs, linked at a coarse granularity. You get sequential access inside a block and cheap restructuring between blocks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same rule, one layer up: why this shows up in LLM serving
&lt;/h2&gt;

&lt;p&gt;If you work on inference rather than data structures, you have met this exact tradeoff wearing a different hat.&lt;/p&gt;

&lt;p&gt;Serving an LLM means holding a &lt;strong&gt;KV cache&lt;/strong&gt; — the keys and values for every token in every active sequence. Naively you allocate one contiguous buffer per sequence, sized to the maximum possible length. That's the array choice: perfectly sequential, prefetcher-friendly reads during attention, and enormous waste, because most sequences never reach the maximum and you cannot hand the slack to anyone else.&lt;/p&gt;

&lt;p&gt;The obvious fix is to allocate per token and link them. That's the linked-list choice, and it fails for exactly the reason above: attention would then walk a pointer chain per token, turning the hottest loop in the system into dependent cache misses.&lt;/p&gt;

&lt;p&gt;What production engines actually do — &lt;strong&gt;PagedAttention&lt;/strong&gt; in vLLM being the well-known case — is the &lt;code&gt;deque&lt;/code&gt; answer: a &lt;strong&gt;linked list of fixed-size blocks&lt;/strong&gt;. Each block holds many tokens contiguously, so attention reads run sequentially inside a block, while a per-sequence block table lets memory be allocated, freed, and even &lt;em&gt;shared&lt;/em&gt; between sequences at block granularity. Contiguity where you iterate, indirection where you restructure.&lt;/p&gt;

&lt;p&gt;The batching story is the same shape. Continuous batching wins partly because it keeps the tensors that the GPU streams through &lt;strong&gt;contiguous and predictable&lt;/strong&gt;; every gather or scatter you introduce is the shuffled-access experiment from earlier, running on far more expensive hardware. Data locality is not a CPU-era detail you've outgrown — the memory wall is &lt;em&gt;wider&lt;/em&gt; on a GPU, not narrower.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five questions worth being able to answer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two O(n) loops over the same data are 3× apart. Why?&lt;/strong&gt; Memory layout: cache lines and prefetching. Big-O counts operations, not the cost of a fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When is a linked list's O(1) insert actually O(1)?&lt;/strong&gt; Only when you already hold the node. Reaching position k first costs k dependent cache misses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why is an array's O(n) shift often faster than a linked list's O(k) traversal?&lt;/strong&gt; The shift is a sequential &lt;code&gt;memmove&lt;/code&gt; the hardware loves; the traversal is serialized misses it can't predict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why is &lt;code&gt;deque.appendleft&lt;/code&gt; fast without being slow to iterate?&lt;/strong&gt; It's a linked list &lt;em&gt;of blocks&lt;/em&gt; — contiguous within, linked between.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When would you genuinely choose a linked structure?&lt;/strong&gt; When you hold positions and need stable references under mutation: LRU caches, allocator free lists, intrusive kernel lists.&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;&lt;strong&gt;Default to the contiguous array&lt;/strong&gt; — a Python &lt;code&gt;list&lt;/code&gt;, a Java &lt;code&gt;ArrayList&lt;/code&gt;, a &lt;code&gt;Vec&lt;/code&gt;, a NumPy array. It's what your hardware was built for, and the prefetcher works for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for a linked structure when you already hold the position you're mutating&lt;/strong&gt; and need references to stay stable while the structure changes around them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you need both&lt;/strong&gt;, use the blocked hybrid — a &lt;code&gt;deque&lt;/code&gt;, a rope, a chunked list. That's the answer nearly every production system converges on, from &lt;code&gt;collections.deque&lt;/code&gt; to a paged KV cache.&lt;/p&gt;

&lt;p&gt;And when a measurement disagrees with the complexity table, the measurement is not wrong. Big-O just wasn't answering that question.&lt;/p&gt;




&lt;p&gt;Every number in this post was executed on the recording machine before it was drawn: Intel Core i5-9400F @ 2.90 GHz, L1d 32 KB/core, L2 256 KB/core, L3 9 MB shared, 64-byte cache line, CPython 3.10.12.&lt;/p&gt;

&lt;p&gt;Watch the &lt;a href="https://youtu.be/sEvygbNcq10" rel="noopener noreferrer"&gt;full 14-minute episode&lt;/a&gt; for the memory-row diagrams, all four code walkthroughs, and the isolation experiment step by step — or the &lt;a href="https://youtube.com/shorts/MyV75rlbKHA" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; if you just want the shape of it.&lt;/p&gt;

</description>
      <category>datastructures</category>
      <category>programming</category>
      <category>computerscience</category>
      <category>performance</category>
    </item>
    <item>
      <title>Processes vs Threads</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 11 Aug 2026 15:39:46 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/processes-vs-threads-5fde</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/processes-vs-threads-5fde</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/7yjKlebrECQ" 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/processes-vs-threads?id=155" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You run code concurrently all the time. But "concurrent" hides a critical choice: are you spawning separate processes or threads inside the same process? That choice decides whether one crash takes down your entire system or stays contained, and whether you're copying data between isolated worlds or racing to read the same memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; A process is its own house; threads are roommates sharing one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Processes: Isolation at the Cost of Weight
&lt;/h2&gt;

&lt;p&gt;When you start a process, the operating system hands it its own private address space. That address space is walled off. Your process can't touch another process's memory—the OS enforces it at the CPU level. If your process crashes, it corrupts only its own memory. The kernel cleans it up. Every other process keeps running untouched.&lt;/p&gt;

&lt;p&gt;This is why browsers put each tab in its own process. One tab runs malicious JavaScript, spins into an infinite loop, or has a memory leak—that tab's process dies. The rest of your browser lives. You close the dead tab and open a new one. Your other tabs don't even hiccup.&lt;/p&gt;

&lt;p&gt;But isolation isn't free. Each process carries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Its own copy of the heap, stack, and memory pages&lt;/li&gt;
&lt;li&gt;Its own file descriptor table, open sockets, and kernel resources&lt;/li&gt;
&lt;li&gt;OS overhead to track and protect it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spawning a process is expensive—milliseconds on modern hardware, but measurably heavier than a thread. And if two processes need to share data, they can't just read the same memory. One process must copy data into a pipe or socket, send it across, and the other process must copy it out and into its own memory. That's overhead on every exchange.&lt;/p&gt;




&lt;h2&gt;
  
  
  Threads: Speed and Sharing, With a Trap
&lt;/h2&gt;

&lt;p&gt;Threads live inside a single process and share that process's entire memory. The kernel doesn't wall them off from each other. When you spawn a thread, you're not duplicating the heap, the file descriptors, or the kernel state—you're just creating a new stack and registering it with the scheduler.&lt;/p&gt;

&lt;p&gt;Spawning a thread is orders of magnitude cheaper than spawning a process. And sharing data is free: both threads read and write the same variables. No copying, no pipes. If you have multi-core hardware and a job that threads can split (parsing a large file, computing in parallel), threads let you put all your cores to work on that one job instantly.&lt;/p&gt;

&lt;p&gt;But shared memory is the trap.&lt;/p&gt;

&lt;p&gt;When two threads touch the same data without coordination, they collide. One thread reads a 64-bit integer while another thread is writing it—the reader sees a torn write (half the old value, half the new value). Two threads increment a counter simultaneously—both read 5, both write 6, and you lost an increment. One thread modifies a linked list while another traverses it—the traverser hits a dangling pointer and crashes.&lt;/p&gt;

&lt;p&gt;To avoid collisions, you add locks (mutexes, semaphores). Thread A locks a resource, does its work, unlocks it. Thread B waits for the lock. But now you've traded one problem for another:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deadlock:&lt;/strong&gt; Thread A holds lock X and waits for lock Y. Thread B holds lock Y and waits for lock X. Both freeze forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock contention:&lt;/strong&gt; Many threads fighting for the same lock spend CPU time spinning, waiting, and context-switching instead of working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragile logic:&lt;/strong&gt; A lock protects a section of code, but a developer forgets to acquire it in one place, and you have a race condition that shows up in production only under load.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And crucially: if one thread crashes (null pointer dereference, stack overflow, segmentation fault), it brings down the entire process. No isolation. All threads die with it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Head-to-Head Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Property&lt;/th&gt;
      &lt;th&gt;Process&lt;/th&gt;
      &lt;th&gt;Thread&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Memory space&lt;/td&gt;
      &lt;td&gt;Own isolated address space&lt;/td&gt;
      &lt;td&gt;Shared within one process&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Startup cost&lt;/td&gt;
      &lt;td&gt;Heavy (copy memory, kernel state)&lt;/td&gt;
      &lt;td&gt;Light (just a stack)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Data sharing&lt;/td&gt;
      &lt;td&gt;Copy via pipe/socket&lt;/td&gt;
      &lt;td&gt;Direct read/write (free but risky)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;One crash&lt;/td&gt;
      &lt;td&gt;Stays contained&lt;/td&gt;
      &lt;td&gt;Kills entire process&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Synchronization&lt;/td&gt;
      &lt;td&gt;Message passing (inherently safe)&lt;/td&gt;
      &lt;td&gt;Locks, mutexes (race conditions, deadlock)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Scaling to cores&lt;/td&gt;
      &lt;td&gt;Hard (separate heaps)&lt;/td&gt;
      &lt;td&gt;Easy (shared memory)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  A Concrete Example: Parsing a Large File
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Using threads:&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;import&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;  &lt;span class="c1"&gt;# Shared across threads
&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&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;parse_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;results&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;chunk_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;parse_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&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="n"&gt;threads&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;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&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;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each thread parses its chunk and locks before appending to the shared &lt;code&gt;results&lt;/code&gt; list. Cheap to spawn, instant sharing—but you must remember the lock or race.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using processes:&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;multiprocessing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Pool&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_data&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parse_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&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;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each worker process parses independently. The Pool framework collects results and sends them back. No locks, no races—each process is isolated. But the framework must serialize the chunks, send them over, deserialize, parse, serialize the results, send them back, and deserialize. That copying is the cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Python Threads Are Different: The GIL
&lt;/h2&gt;

&lt;p&gt;Python's Global Interpreter Lock (GIL) means only one thread can execute Python bytecode at a time. If you spawn 10 threads in Python, they do take turns, but they don't run in true parallel on multi-core systems—the GIL ensures only one holds the interpreter at once. CPU-bound threads don't scale; I/O-bound threads do (one thread blocks on a socket read, another grabs the GIL and runs).&lt;/p&gt;

&lt;p&gt;For CPU-bound parallel work in Python, processes (via &lt;code&gt;multiprocessing&lt;/code&gt;) sidestep the GIL entirely. Each process has its own interpreter and its own GIL, so they run in true parallel. The cost is process creation overhead and data serialization, but you get real parallelism.&lt;/p&gt;




&lt;h2&gt;
  
  
  When LLM Inference Matters: Batching Over Threads
&lt;/h2&gt;

&lt;p&gt;If you're serving LLM inference (or any I/O-heavy workload), concurrency is critical. Many requests come in, and you want to batch them and serve them in parallel. At first glance, threads look ideal—cheap, share memory for the model weights.&lt;/p&gt;

&lt;p&gt;But shared memory on a model is a trap. If multiple threads try to read from the same model weights while one is loading new weights, you have data races. Most production inference engines (vLLM, Ray, TensorFlow Serving) use &lt;strong&gt;process isolation per request batch or per GPU&lt;/strong&gt;, not threads, because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Process isolation prevents one corrupted inference from poisoning the weights.&lt;/li&gt;
&lt;li&gt;Modern inference is I/O-heavy (reading from disk, network requests)—threads excel at this but aren't the bottleneck.&lt;/li&gt;
&lt;li&gt;GPUs naturally parallelize batches, so multi-threading inside a single process adds complexity without proportional gain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern is: use async I/O (coroutines) to queue many requests cheaply, batch them, and hand them off to a process or GPU worker. Threads between request and batch are overkill; processes between batch and inference are safety.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Reach for threads when:&lt;/strong&gt; you have fast, in-memory work on shared data and need to use all your cores (numeric computation, graph traversal)—and your language doesn't have a GIL, or your work is I/O-bound. Keep lock sections tiny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for processes when:&lt;/strong&gt; you need isolation (crash safety, multi-tenancy), you're doing CPU-bound work in Python, or you're running independent jobs (worker pools, load-balanced services).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most real systems use both:&lt;/strong&gt; a main process with a thread pool for I/O, or a process pool running I/O-async inside each worker, or a browser spawning tabs as processes and using threads inside each for UI rendering and JavaScript execution.&lt;/p&gt;

&lt;p&gt;The choice isn't one or the other—it's knowing the tradeoff and picking the right tool for the layer you're building.&lt;/p&gt;




&lt;p&gt;Watch the &lt;a href="https://software-engineer-blog.com" rel="noopener noreferrer"&gt;90-second reel&lt;/a&gt; for a quick visual breakdown of this exact tradeoff.&lt;/p&gt;

</description>
      <category>processesthreads</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Active-Active vs Active-Passive: You Had a Second Server, So Why Were You Down for 41 Minutes?</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:32:01 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/active-active-vs-active-passive-you-had-a-second-server-so-why-were-you-down-for-41-minutes-215</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/active-active-vs-active-passive-you-had-a-second-server-so-why-were-you-down-for-41-minutes-215</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/t1VpbLkKGoU" 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/active-active-vs-active-passive-you-had-a-second-server-so-why-were-you-down-for-41-minutes?id=154" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two in the morning. A payments service, one database, and a second identical machine sitting right next to it, copying every change as it happens. Then the first machine's disk controller dies — no warning, no slow decline, it just stops answering.&lt;/p&gt;

&lt;p&gt;And you had planned for this. There &lt;strong&gt;is&lt;/strong&gt; a second machine, right there, fully in sync. It has been in sync for ten months.&lt;/p&gt;

&lt;p&gt;The service is down for &lt;strong&gt;forty-one minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not because the standby was missing. Not because the data was gone. The standby was fine the whole time. Every one of those forty-one minutes was spent &lt;em&gt;switching over to it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the question this article answers: you had a second server — so why were you down for forty-one minutes?&lt;/p&gt;




&lt;h2&gt;
  
  
  The subject: redundancy, and its two shapes
&lt;/h2&gt;

&lt;p&gt;"We have a second one" is the same sentence in both designs. It means two completely different things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active-passive&lt;/strong&gt; — the second machine waits. It's kept up to date, and it gets &lt;strong&gt;promoted&lt;/strong&gt; when the first one dies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active-active&lt;/strong&gt; — both machines take live traffic, all the time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things this is &lt;em&gt;not&lt;/em&gt;. It isn't &lt;strong&gt;backups&lt;/strong&gt;: a backup is a copy of yesterday, this is a copy of right now. And it isn't &lt;strong&gt;horizontal scaling&lt;/strong&gt; — that's about adding capacity, and it's a different problem. This is the shape that sits underneath every cloud provider's version of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  One picture first
&lt;/h3&gt;

&lt;p&gt;Think of a building with an &lt;strong&gt;emergency generator in the basement&lt;/strong&gt;. It's fuelled. It's serviced. Once a month somebody starts it for ten minutes with nothing plugged into it, ticks a box, and switches it off. That building is &lt;strong&gt;active-passive&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now think of a building fed by &lt;strong&gt;two power lines from two different substations&lt;/strong&gt;, each carrying half the load, all day. One line fails and the other picks up the rest. Nothing starts up. Nothing gets promoted. The lights don't even flicker. That building is &lt;strong&gt;active-active&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the part that matters. In the second building you already knew the second line worked — &lt;strong&gt;because the lights it was powering were on&lt;/strong&gt;. The proof was the service itself. In the first building, the generator has proved it can &lt;em&gt;start&lt;/em&gt;. It has never once proved it can &lt;em&gt;carry the building&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Active-passive, in code
&lt;/h2&gt;

&lt;p&gt;Two machines in a config, one named primary and one named standby. Now look at the routing function — the thing that decides where an incoming request goes:&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;# config.py — addresses from RFC 5737 (documentation range)
&lt;/span&gt;&lt;span class="n"&gt;NODES&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;primary&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;host&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;192.0.2.10&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;role&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;primary&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;standby&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;host&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;192.0.2.11&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;role&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;standby&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;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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;NODES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# ...that is the entire function
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health_check&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;ping&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;    &lt;span class="c1"&gt;# nobody ever asks the standby
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;strong&gt;There is no branch in it.&lt;/strong&gt; There is no condition. The word &lt;code&gt;standby&lt;/code&gt; appears in the config, and then it never appears again anywhere on the path a request takes.&lt;/p&gt;

&lt;p&gt;Look at the health check too — it only ever asks about the primary. Nobody is asking the standby whether it &lt;em&gt;could&lt;/em&gt; serve a request, because nobody ever intends to send it one. That is what passive means: &lt;strong&gt;the second machine is invisible to production until the day you need it.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Failover is not an instant
&lt;/h3&gt;

&lt;p&gt;People say "failover" as if it were a single moment. It's a state machine, and naming the states is worth doing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serving&lt;/strong&gt; — everything normal, all traffic to A.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suspect&lt;/strong&gt; — health checks are failing, but you're not sure yet. Was that the machine, or the network &lt;em&gt;between you and&lt;/em&gt; the machine?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promoted&lt;/strong&gt; — you've committed. B is the primary now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then there's the transition nobody plans for: &lt;strong&gt;the old machine comes back&lt;/strong&gt;. It still has your data. It still believes it's the primary. If it starts accepting writes again, you now have two primaries and two versions of the truth. That last arrow is where most real failover incidents actually go wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  The four steps, and what they cost
&lt;/h3&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;failover&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. DETECT — three failed checks, ten seconds apart.
&lt;/span&gt;    &lt;span class="c1"&gt;#    Three, not one: a single missed check is usually a network hiccup,
&lt;/span&gt;    &lt;span class="c1"&gt;#    and promoting on a hiccup is worse than waiting.
&lt;/span&gt;    &lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consecutive_failures&lt;/span&gt;&lt;span class="o"&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;interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# ~30 s
&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. FENCE — make certain the old machine cannot accept another write.
&lt;/span&gt;    &lt;span class="c1"&gt;#    Cut its network, revoke its credentials, power it off.
&lt;/span&gt;    &lt;span class="c1"&gt;#    Skipping this is exactly how you get two primaries.
&lt;/span&gt;    &lt;span class="nf"&gt;fence&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                            &lt;span class="c1"&gt;# ~2 s
&lt;/span&gt;
    &lt;span class="c1"&gt;# 3. PROMOTE — standby applies what it hadn't caught up on, opens for writes.
&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;NODES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;standby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                          &lt;span class="c1"&gt;# ~15 s
&lt;/span&gt;
    &lt;span class="c1"&gt;# 4. REPOINT — every client still holds the old address.
&lt;/span&gt;    &lt;span class="c1"&gt;#    Until they let go of it, the promotion changed nothing they can see.
&lt;/span&gt;    &lt;span class="nf"&gt;update_dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&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;# ~60 s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it up, for a failover where everything is automated and nothing goes wrong: 30 + 2 + 15 + 60 = &lt;strong&gt;107 seconds&lt;/strong&gt;. Just under two minutes, and that's the good number — the one you can defend in a design review.&lt;/p&gt;

&lt;p&gt;The number in the story at the top was &lt;strong&gt;forty-one minutes&lt;/strong&gt;. And the difference between them is not technology. The difference is that two of those four steps &lt;strong&gt;had never been run before&lt;/strong&gt;, so somebody had to work out what they were while the site was down.&lt;/p&gt;

&lt;h3&gt;
  
  
  An idle standby is an untested standby
&lt;/h3&gt;

&lt;p&gt;Your standby has been sitting there, in sync, for three hundred days. In that time your service handled about two and a half billion requests, and the standby served &lt;strong&gt;zero&lt;/strong&gt; of them. What has it actually proved?&lt;/p&gt;

&lt;p&gt;A few real things, and they're worth monitoring: the process is running, replication is keeping up, there's disk space.&lt;/p&gt;

&lt;p&gt;Now look at what it has &lt;strong&gt;not&lt;/strong&gt; proved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That the promote command works while the machine is under load.&lt;/li&gt;
&lt;li&gt;That the address change actually reaches your clients.&lt;/li&gt;
&lt;li&gt;That your application reconnects, instead of sitting there holding a pool of dead connections.&lt;/li&gt;
&lt;li&gt;That the alert wakes up a human being.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every single one of those runs &lt;strong&gt;for the first time during your worst hour&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Active-active, in code
&lt;/h2&gt;

&lt;p&gt;Same two machines. Now both are in the pool, both have a weight, and the routing function asks which ones are healthy:&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;POOL&lt;/span&gt; &lt;span class="o"&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;host&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;192.0.2.10&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;healthy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host&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;192.0.2.11&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;healthy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weight&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;live&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="n"&gt;POOL&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;      &lt;span class="c1"&gt;# asks about BOTH, every second
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;weighted_choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live&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;on_health_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthy&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="bp"&gt;False&lt;/span&gt;                       &lt;span class="c1"&gt;# ...that is the whole handler
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch what the failure handler does. &lt;strong&gt;It sets a flag to false.&lt;/strong&gt; There's no promote. There's no fence. There's no address change — because the address was never pointing at a machine, it was pointing at the &lt;strong&gt;pool&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When node B dies at 02:14, three seconds later the healthy list has one entry in it, and you're running at 50% capacity and &lt;strong&gt;100% availability&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And here's the quiet part: &lt;strong&gt;that same line runs every single time you deploy.&lt;/strong&gt; The code that handled your outage had already run a thousand times this month.&lt;/p&gt;

&lt;p&gt;That's the real argument for active-active, and it isn't the one people usually give. People say it's about capacity, or being closer to users. Both are true, and both are secondary. The actual argument is that active-active &lt;strong&gt;deletes the failover procedure instead of trying to improve it&lt;/strong&gt; — there's no promotion step to get wrong because nothing is promoted, no fencing problem because there's no single writer to fence, and the second machine is continuously proven to work for the simple reason that &lt;em&gt;it is working&lt;/em&gt;. If it were broken, you'd have found out on an ordinary Tuesday afternoon, not at two in the morning.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bill for active-active
&lt;/h2&gt;

&lt;p&gt;So why does anybody still run active-passive? Because active-active isn't free redundancy. It's a &lt;strong&gt;trade&lt;/strong&gt;, and you should hear the bill before you sign it.&lt;/p&gt;

&lt;p&gt;Off your plate: no promotion, no fencing, no address change, no path that has never been executed.&lt;/p&gt;

&lt;p&gt;Handed to you instead:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Two live writers, so you own conflicts
&lt;/h3&gt;

&lt;p&gt;One product, ten units in stock. Two customers each buy one, forty milliseconds apart, landing on different machines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A: read stock=10 → 10-1 → write 9
B: read stock=10 → 10-1 → write 9      # last write wins
                              stock = 9   ← two sales, one decrement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You didn't get an error. You didn't get a conflict warning. You got data that is quietly, plausibly &lt;strong&gt;wrong&lt;/strong&gt;.&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="c1"&gt;-- the fix: every row carries a version, and a write must say&lt;/span&gt;
&lt;span class="c1"&gt;-- which version it believes it is replacing&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- 0 rows updated → somebody moved first → raise a conflict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's one extra field, plus a real decision about what your application does when it fires.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shared session state
&lt;/h3&gt;

&lt;p&gt;Anything you were keeping in memory on one machine has to move somewhere &lt;strong&gt;both&lt;/strong&gt; of them can see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Capacity planning changes
&lt;/h3&gt;

&lt;p&gt;With two sites, each has to be able to carry the whole load alone — which means each one normally runs at about half.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Split brain
&lt;/h3&gt;

&lt;p&gt;The network between your two sites breaks, but both sites are perfectly healthy and both can still reach users. Each half looks around, sees no partner, and quite reasonably concludes the other one died. So both keep serving. Both keep writing. When the link comes back you have two divergent versions of the same data and no automatic way to say which is real.&lt;/p&gt;

&lt;p&gt;The standard answer is a &lt;strong&gt;quorum&lt;/strong&gt;: you need a majority to keep accepting writes, which is why these systems are built with &lt;strong&gt;three nodes rather than two&lt;/strong&gt; — so a split always leaves one side in the minority, and the side that loses the vote stops serving.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Being briefly &lt;em&gt;unavailable&lt;/em&gt; is something you recover from. Being briefly &lt;em&gt;wrong&lt;/em&gt;, silently, very often is not.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The honest comparison
&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;Active-Passive&lt;/th&gt;
&lt;th&gt;Active-Active&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Second machine&lt;/td&gt;
&lt;td&gt;Idle, invisible to production&lt;/td&gt;
&lt;td&gt;Live, serving traffic&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Routing function&lt;/td&gt;
&lt;td&gt;No branch — returns the primary&lt;/td&gt;
&lt;td&gt;Picks from the healthy pool&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Failure response&lt;/td&gt;
&lt;td&gt;Detect, fence, promote, repoint&lt;/td&gt;
&lt;td&gt;Set a flag to false&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Best-case downtime&lt;/td&gt;
&lt;td&gt;~107 s automated (41 min if untested)&lt;/td&gt;
&lt;td&gt;~seconds, capacity drops to 50%&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Is the spare proven?&lt;/td&gt;
&lt;td&gt;No — first run is your worst hour&lt;/td&gt;
&lt;td&gt;Yes — it ran today&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Write conflicts&lt;/td&gt;
&lt;td&gt;None (one writer)&lt;/td&gt;
&lt;td&gt;Yours to solve (versioning)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Session state&lt;/td&gt;
&lt;td&gt;Can live in memory&lt;/td&gt;
&lt;td&gt;Must be shared&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Split brain&lt;/td&gt;
&lt;td&gt;Not asked&lt;/td&gt;
&lt;td&gt;Needs a quorum (3 nodes)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Normal utilisation&lt;/td&gt;
&lt;td&gt;~100% of one machine&lt;/td&gt;
&lt;td&gt;~50% of each&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What this looks like when you serve an LLM
&lt;/h2&gt;

&lt;p&gt;The same two shapes decide how you run inference, and the split is unusually clean — which makes it a good way to check you've understood the rule.&lt;/p&gt;

&lt;p&gt;Your &lt;strong&gt;model-serving replicas are stateless&lt;/strong&gt;: a GPU worker holding weights takes a request, returns tokens, and remembers nothing about you afterwards. That's the free case for active-active. Put every replica behind one pool, health-check them all continuously, and a dead GPU node is a flag flip and a capacity drop — not a promotion. You also get the thing an idle standby can never give you: a spare GPU that has been proven to load the weights and answer, because it answered a minute ago. A "warm standby GPU" that has never served a token is exactly the generator in the basement, and it costs the same per hour as one that's working.&lt;/p&gt;

&lt;p&gt;Two caveats worth naming, because they're the same bill in new clothes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The KV cache is session state.&lt;/strong&gt; Multi-turn conversations and prefix caching make a replica &lt;em&gt;feel&lt;/em&gt; stateful — routing a follow-up to a different node throws away the cached prefix and you pay to prefill again. That's the "shared session state" line on the bill, and the usual answer is the usual answer: session-affinity routing, or lift the cache somewhere both replicas can see. Note that this is a &lt;strong&gt;performance&lt;/strong&gt; cost, not a correctness one — you get a slower answer, not a wrong one, which is exactly why the stateless tier is still the easy case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stateful tier behind it is not stateless.&lt;/strong&gt; Your vector store, your metadata database, the table you write conversations and evals into — those have all the conflict and split-brain problems from the previous section. A vector index accepting writes on two sides can silently diverge in what it returns, and "the retriever quietly stopped seeing some documents" is the RAG version of the lost update: no error, plausible output, wrong answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is the general rule, arrived at from the other direction.&lt;/p&gt;




&lt;h2&gt;
  
  
  So which should you build? Almost certainly both
&lt;/h2&gt;

&lt;p&gt;Not one for the whole company — &lt;strong&gt;one per layer&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web servers, API processes, model replicas&lt;/strong&gt; — anything that doesn't remember anything between one request and the next: run those &lt;strong&gt;active-active&lt;/strong&gt;. They have no conflicts to have, so you get all of the upside and pay none of the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your main database, the single source of truth&lt;/strong&gt; — &lt;strong&gt;active-passive&lt;/strong&gt; is usually the honest answer. One writer, one version of the truth, a replica standing ready. That is not a compromise, that's the right tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anyone who tells you their entire system is active-active is describing the front of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The sentence to keep
&lt;/h2&gt;

&lt;p&gt;You are not choosing how many machines you own. In both designs, you own two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are choosing whether failing over is a routine or an event.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Active-passive makes it an &lt;em&gt;event&lt;/em&gt;: a procedure that runs about once a year, at the worst possible moment, carried out by a tired person reading a document. Active-active makes it a &lt;em&gt;routine&lt;/em&gt;: a flag that flips, on a path that already ran today.&lt;/p&gt;

&lt;p&gt;And if you do keep an active-passive tier — which you probably should — then the only honest thing to do is to &lt;strong&gt;fail over to it on purpose, on a Tuesday afternoon, while everybody is awake.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The full walkthrough, with all four failover steps as real code: &lt;a href="https://youtu.be/t1VpbLkKGoU" rel="noopener noreferrer"&gt;watch the 11-minute episode.&lt;/a&gt; Short on time? &lt;a href="https://youtube.com/shorts/PA0KzFnN0yc" rel="noopener noreferrer"&gt;Here's the 2-minute version.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>devops</category>
    </item>
    <item>
      <title>Hash Tables Explained in Detail: Why a Dict Lookup Beats a List Scan by 62,000x</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 10 Aug 2026 10:12:11 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/hash-tables-explained-in-detail-why-a-dict-lookup-beats-a-list-scan-by-62000x-3ocd</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/hash-tables-explained-in-detail-why-a-dict-lookup-beats-a-list-scan-by-62000x-3ocd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/BiAU7Rs94qg" 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/hash-tables-explained-in-detail-why-a-dict-lookup-beats-a-list-scan-by-62000x?id=153" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two hundred thousand users in a list. A login handler that looks for one email. It works, it passes review, and it costs &lt;strong&gt;4.4 milliseconds&lt;/strong&gt; — which sounds like nothing until two thousand people sign in per second and the box is suddenly doing 8.8 CPU-seconds of work every second. Nothing crashes. Nothing shows up in the logs. The service just stops answering.&lt;/p&gt;

&lt;p&gt;The identical lookup out of a Python dict takes &lt;strong&gt;71 nanoseconds&lt;/strong&gt;. About 62,000 times faster, on the same data and the same machine — and &lt;em&gt;not&lt;/em&gt; because the dictionary searches faster.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model:&lt;/strong&gt; A hash table never searches. It &lt;strong&gt;computes&lt;/strong&gt; where the answer lives. The list looks at a hundred thousand rows; the dictionary looks at one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is unit 11 of "the missing CS degree", and it is the one data structure worth understanding properly, because you already use one in every dict, every set, and every "have I seen this before" check you have ever written.&lt;/p&gt;




&lt;h2&gt;
  
  
  The code you would write first, and it is not wrong
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_users&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;              &lt;span class="c1"&gt;# 200,000 rows, in a plain list
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_user&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# one string compare, per row, in order
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&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;email&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;u&lt;/span&gt;              &lt;span class="c1"&gt;# average: 100,000 comparisons
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                   &lt;span class="c1"&gt;# worst case: all 200,000
&lt;/span&gt;
&lt;span class="c1"&gt;# $ python bench.py
#   find_user(...)          4.419 ms     &amp;lt;- per login, average row
#   2,000 logins/second -&amp;gt;  8.8 CPU-seconds of work, per second
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing bad in there. It is correct, it is readable, and for a list of twenty it is the right answer. The problem is the &lt;strong&gt;shape&lt;/strong&gt; of the cost: double the users, double the work, every time, forever. That shape is called O(n), and changing it is the entire reason hash tables exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build one, do not import one
&lt;/h2&gt;

&lt;p&gt;The whole idea fits in nine lines, and only one of them matters:&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;# toytable.py  —  the whole idea, in nine lines
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HashTable&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt; &lt;span class="o"&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;_&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="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;   &lt;span class="c1"&gt;# 8 empty slots
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;                                 &lt;span class="c1"&gt;# how many keys we hold
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="c1"&gt;# &amp;lt;- THIS is the hash table
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;hash&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="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# any key  -&amp;gt;  0..7
&lt;/span&gt;
    &lt;span class="k"&gt;def&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;self&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;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_index&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="c1"&gt;# ONE slot. never the rest.
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&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;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;KeyError&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hash()&lt;/code&gt; is built into Python: hand it a string, it hands back a large integer. Modulo the number of buckets folds that integer down into the range 0 to 7. &lt;strong&gt;Key in, slot number out&lt;/strong&gt; — that one line &lt;em&gt;is&lt;/em&gt; the hash table. Everything else in the file is bookkeeping.&lt;/p&gt;

&lt;p&gt;Note what &lt;code&gt;get&lt;/code&gt; does not do: it never touches the other seven slots. That is where the 62,000x comes from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Eight names, eight slots — and the collision is not a bug
&lt;/h2&gt;

&lt;p&gt;You are folding billions of possible numbers into eight slots. Of course two of them land in the same place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;h("grace")    =        98,615,224   -&amp;gt;  % 8 = 0
h("alan")     =         2,996,632   -&amp;gt;  % 8 = 0
h("edsger")   =     2,987,424,544   -&amp;gt;  % 8 = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three keys, one slot. Meanwhile slots 2, 3 and 5 got nothing at all. Neither of those is a defect — it is arithmetic. &lt;strong&gt;The question is never how to avoid a collision. It is what you do when one happens.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The answer here is &lt;em&gt;chaining&lt;/em&gt;: a slot does not hold one entry, it holds a little list.&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;# toytable.py  —  set: the two lines that resolve a collision
&lt;/span&gt;
    &lt;span class="k"&gt;def&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;self&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;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_index&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;for&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bucket&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;pair&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# this exact key is already in the slot
&lt;/span&gt;                &lt;span class="n"&gt;pair&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;       &lt;span class="c1"&gt;# so it is an update, not an insert
&lt;/span&gt;                &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;bucket&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# a DIFFERENT key, same slot: it queues up
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&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;# &amp;gt;&amp;gt;&amp;gt; ht.buckets[0]
# [['grace', 2], ['alan', 4], ['edsger', 5]]    &amp;lt;- three keys, one slot
# &amp;gt;&amp;gt;&amp;gt; ht.get('alan')
# 4                                             &amp;lt;- 2 compares, not 200,000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of collision handling is that one &lt;code&gt;append&lt;/code&gt;. Getting a key back means: the right slot, then a short walk down its list. &lt;strong&gt;Short&lt;/strong&gt; is the word doing the work — two or three, not two hundred thousand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Load factor: the number that decides whether any of this stays fast
&lt;/h2&gt;

&lt;p&gt;A hash table is fast while the chains are short. The load factor is what keeps them short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;load factor  =  keys stored  ÷  slots available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight keys in eight slots is a load factor of 1.0. It is the &lt;strong&gt;average number of keys sitting in a slot&lt;/strong&gt;, which is the same thing as the number of comparisons a lookup costs. So it is not a tidiness metric — it is your lookup cost, written as one number.&lt;/p&gt;

&lt;p&gt;Measured over 100,000 real keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;load factor&lt;/th&gt;
&lt;th&gt;average chain&lt;/th&gt;
&lt;th&gt;longest chain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;1.4&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;1.6&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;2.3&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight times as crowded costs eight times the comparisons, and it degrades &lt;em&gt;smoothly&lt;/em&gt; — which is exactly why nobody notices until it matters.&lt;/p&gt;

&lt;p&gt;This is also why the textbook writes O(1) as &lt;strong&gt;"amortised, average case"&lt;/strong&gt;. The average case is a statement about the load factor, and the load factor is not a fact about your data. It is something you actively keep down.&lt;/p&gt;


&lt;h2&gt;
  
  
  The resize, and why every key has to move
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# toytable.py  —  the four lines that keep the O(1) true
&lt;/span&gt;
    &lt;span class="k"&gt;def&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;self&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;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;bucket&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# over three quarters full
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_resize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;# so make the table bigger
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt; &lt;span class="o"&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;_&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&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;# twice as many slots
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;old&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;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&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;k&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="c1"&gt;# EVERY key moves
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It cannot copy the pairs across into the same positions. &lt;code&gt;_index&lt;/code&gt; divides by the number of buckets, and that number just changed:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;h("grace") % 8  = 0
h("grace") % 16 = 8      &amp;lt;- same key, same hash, different slot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;So every key belongs somewhere new, and re-inserting all of them is what "rehashing" means. That occasional O(n) pass, spread over all the cheap inserts that preceded it, is the &lt;em&gt;amortised&lt;/em&gt; in "amortised O(1)".&lt;/p&gt;

&lt;p&gt;On our eight keys, the three-deep chain in slot 0 became a one and a two. That is all a resize ever does: it buys the chains room by making the divisor bigger.&lt;/p&gt;


&lt;h2&gt;
  
  
  Chaining vs open addressing — you use both every day
&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;Chaining&lt;/th&gt;
      &lt;th&gt;Open addressing&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;On a collision&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;The slot holds a list; the new pair is appended to it&lt;/td&gt;
      &lt;td&gt;Probe on to another slot by a fixed rule — there are no lists&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Memory layout&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Every entry costs a pointer, and the chain is scattered&lt;/td&gt;
      &lt;td&gt;Cache-friendly — probing stays inside one block of memory&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Deleting&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Simple: unlink it from the list&lt;/td&gt;
      &lt;td&gt;Genuinely awkward — you have to leave a tombstone behind&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Who uses it&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Our toy table, Java's &lt;code&gt;HashMap&lt;/code&gt;; Go chains overflow buckets&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;Python's &lt;code&gt;dict&lt;/code&gt;&lt;/strong&gt;, and Ruby's &lt;code&gt;Hash&lt;/code&gt;
&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither one &lt;em&gt;avoids&lt;/em&gt; collisions. Nothing avoids collisions. They are two different answers to "this slot is taken".&lt;/p&gt;




&lt;h2&gt;
  
  
  When O(1) becomes O(n) — and the attack built on it
&lt;/h2&gt;

&lt;p&gt;One bad hash function turns your hash table straight back into the list you replaced:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;4,000 keys&lt;/th&gt;
&lt;th&gt;longest chain&lt;/th&gt;
&lt;th&gt;lookup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;an ordinary hash&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.4 µs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;every key forced into slot 0&lt;/td&gt;
&lt;td&gt;4,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;74.5 µs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;186 times slower.&lt;/strong&gt; Same table, same keys, same machine — the structure is identical, only the hash changed.&lt;/p&gt;

&lt;p&gt;And if an attacker can guess your hash function, they can send you exactly those keys on purpose. That is a real denial-of-service class, and it is why CPython randomises the hash of every string at start-up.&lt;/p&gt;


&lt;h2&gt;
  
  
  Three details about the dict you already use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CPython uses &lt;strong&gt;open addressing&lt;/strong&gt;, not chaining, and grows the table at roughly two thirds full.&lt;/li&gt;
&lt;li&gt;Since 3.7 a dict keeps &lt;strong&gt;insertion order&lt;/strong&gt; — that comes from a second, compact array of indices, not from anything about hashing.&lt;/li&gt;
&lt;li&gt;A key has to be hashable, which in practice means immutable: &lt;code&gt;{[1, 2]: "x"}&lt;/code&gt; raises &lt;code&gt;unhashable type: list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hash(1)&lt;/code&gt;, &lt;code&gt;hash(1.0)&lt;/code&gt; and &lt;code&gt;hash(True)&lt;/code&gt; are all &lt;code&gt;1&lt;/code&gt; — so &lt;code&gt;{1: "a", 1.0: "b", True: "c"}&lt;/code&gt; is a dict with &lt;strong&gt;one&lt;/strong&gt; key, holding &lt;code&gt;"c"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  The same trade-off in LLM serving
&lt;/h2&gt;

&lt;p&gt;This is not a legacy-backend concern that AI work has moved past. The inference stack is held together by hash tables, and by their one limitation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefix / KV-cache reuse.&lt;/strong&gt; Serving engines like vLLM cache blocks of KV state and look them up by a &lt;strong&gt;hash of the token prefix&lt;/strong&gt;. Two requests that share an opening system prompt hit the same block and skip recomputing it. That lookup has to be O(1) on every single request, so it is exactly the structure above — and the "hash keys must be deterministic" rule is why block hashing has to be stable across replicas or the cache silently stops hitting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokenizer vocabulary.&lt;/strong&gt; Every token-to-id lookup is a hash map, run tens of thousands of times per second. It is the definition of "cheap hash function matters".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact-match response caching and dedup.&lt;/strong&gt; "Have I answered this exact prompt before?", "have I already embedded this chunk?" — both are set membership, which is the one thing a hash table is unbeatable at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And what it cannot do is why vector search exists.&lt;/strong&gt; Hashing deliberately destroys the relationship between keys. A semantic cache needs the &lt;em&gt;nearest&lt;/em&gt; prompt, not the identical one, and "nearest" is precisely the query a hash table cannot answer. That is why retrieval runs on an ANN index and not a dict — the same reason a database index is a B-tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule transfers exactly: hash for identity, something ordered for proximity.&lt;/p&gt;


&lt;h2&gt;
  
  
  When a hash table is the wrong choice
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Unbeatable at&lt;/th&gt;
      &lt;th&gt;Cannot do it at all&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Is this exact key present?&lt;/td&gt;
      &lt;td&gt;Every key between 10 and 50&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The value for this exact key&lt;/td&gt;
      &lt;td&gt;The smallest key, or the next key after this one&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Have I already seen this?&lt;/td&gt;
      &lt;td&gt;Anything starting with "sm", or sorted output&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Dedup, caches, sets, indexes by id&lt;/td&gt;
      &lt;td&gt;The nearest match, or a predictable worst case&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hashing throws away order to buy you speed, and sometimes order was the thing you needed. It costs memory, too: a thousand integers in a list is about 8 kilobytes; a thousand pairs in a dict is about 37.&lt;/p&gt;




&lt;h2&gt;
  
  
  The whole thing in one breath
&lt;/h2&gt;

&lt;p&gt;A hash table turns a key into a slot number by arithmetic instead of searching, which is why a dict lookup is 71 nanoseconds where a list scan is 4.4 milliseconds. Folding a huge number space into a small array &lt;em&gt;must&lt;/em&gt; produce collisions, so a slot holds a short chain (or probes on to the next slot). Chains stay short only while the load factor stays low, which is why the table doubles at about three quarters full and rehashes every key — the divisor changed, so every key belongs somewhere new. That occasional O(n) pass is the "amortised" in amortised O(1), and a bad or attacker-chosen hash collapses the whole thing back to O(n).&lt;/p&gt;




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

&lt;p&gt;Reach for a hash table by default for anything keyed by identity: lookups by id, membership, dedup, caches. It is the best constant-factor win in everyday code, and the mental model is one sentence — it computes the address, it does not search for it.&lt;/p&gt;

&lt;p&gt;Reach for something else the moment the question involves &lt;strong&gt;order or proximity&lt;/strong&gt;: ranges, prefixes, min/max, sorted scans, nearest match. Name the replacement out loud — a B-tree, a sorted array, a trie, an ANN index — because in an interview, and in a design review, that is the half of the answer most people leave out.&lt;/p&gt;

&lt;p&gt;Watch the &lt;a href="https://youtu.be/BiAU7Rs94qg" rel="noopener noreferrer"&gt;full 13-minute walkthrough&lt;/a&gt; for the table built line by line, the collision and the resize on screen, and five real interview questions with an answer skeleton for each — or the &lt;a href="https://youtu.be/F8PYTCLhTXc" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; for the short of it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>computerscience</category>
      <category>datastructures</category>
    </item>
    <item>
      <title>Write-Through vs Write-Back Caching — Explained in Detail</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sun, 09 Aug 2026 11:43:32 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/write-through-vs-write-back-caching-explained-in-detail-2oee</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/write-through-vs-write-back-caching-explained-in-detail-2oee</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/7rsWn-AYUUQ" 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/write-through-vs-write-back-caching-explained-in-detail?id=152" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A customer fixes their delivery address at 20:31. Green tick, "Saved." At 20:32 a routine deploy restarts the service. At 08:14 the next morning the parcel ships to the old address. Nothing crashed. The log tail is clean: zero errors, zero alerts, zero exceptions.&lt;/p&gt;

&lt;p&gt;Every explanation of caching teaches the &lt;strong&gt;read&lt;/strong&gt; path — you ask for something, the cache has it, you skip the database, everything is fast. Almost nobody tells you what happens on a &lt;strong&gt;write&lt;/strong&gt;. That is where this class of bug lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model:&lt;/strong&gt; A cache is not a shortcut. It is a &lt;strong&gt;second copy&lt;/strong&gt;. The only thing a write strategy decides is &lt;em&gt;which copy becomes true first&lt;/em&gt; — and how long the other one is allowed to be wrong.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The code you already have
&lt;/h2&gt;

&lt;p&gt;Most services never chose a write strategy. They chose a read strategy — cache-aside — and left the write path empty:&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;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;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# the read path everyone writes
&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="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;fetch_one&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;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;order_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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_address&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;addr&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 orders SET addr = ? 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;addr&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# ...and nothing at all happens to the cache
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a write strategy — an accidental one. The database is right, the cache is stale, and the staleness lasts until the key expires. Which is exactly how a "saved" address gets served back as the old address.&lt;/p&gt;




&lt;h2&gt;
  
  
  Write-through: the database first, then the cache
&lt;/h2&gt;

&lt;p&gt;Write-through makes both copies move together. Commit the durable write, then update the cache — in that order, always:&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;update_address&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;addr&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 orders SET addr = ? 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;addr&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                        &lt;span class="c1"&gt;# durable first
&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# then the second copy
&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two copies can never disagree for longer than the microsecond between those lines. If the process dies after &lt;code&gt;commit()&lt;/code&gt;, the database is already correct and the cache just misses. Nothing is lost.&lt;/p&gt;

&lt;p&gt;The bill comes on the hot path. Measured on a real database, not estimated:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;operation&lt;/th&gt;
&lt;th&gt;cost per write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;durable write (commit + fsync)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.948 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same value into an in-memory cache&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0012 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;durable writes per second, one connection&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;171&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Write-through means every single change — forever — pays that ~6 ms, and your write throughput per connection is capped somewhere near 171/s. For a settings page nobody notices. For a counter updated on every request, that ceiling &lt;em&gt;is&lt;/em&gt; your outage.&lt;/p&gt;


&lt;h2&gt;
  
  
  Write-back: write the cache, say yes, flush later
&lt;/h2&gt;

&lt;p&gt;Write-back (also called write-behind) inverts it. The cache takes the write, the user is told yes immediately, and a background flusher pushes the changes to the database in batches:&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;dirty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;                             &lt;span class="c1"&gt;# key -&amp;gt; newest value not yet in the DB
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_address&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;addr&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dirty&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;             &lt;span class="c1"&gt;# remember it needs flushing
&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;# the user is told YES, right here
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flusher&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;                         &lt;span class="c1"&gt;# background, every 5 seconds
&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;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="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dirty_now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dirty&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="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;with&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;transaction&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&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 orders SET addr = ? 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;value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Acknowledgements now cost about a microsecond instead of about six milliseconds, and the database absorbs a burst as a trickle.&lt;/p&gt;
&lt;h3&gt;
  
  
  The trap
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Write-back does not make the write faster. It makes the write a promise.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the window between the acknowledgement and the flush, the only copy of that fact is in RAM, on one machine. Here is the loss, reproduced:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt; save_write_back("o-4471", "Bahnhofstrasse 12")
'saved'                                  # the user watched this succeed
&amp;gt;&amp;gt;&amp;gt; cache["o-4471"]
'Bahnhofstrasse 12'

# ...the process restarts before the flusher runs. RAM is gone.

&amp;gt;&amp;gt;&amp;gt; db.execute("SELECT addr FROM orders WHERE id='o-4471'").fetchone()
None                                     # not stale. NOT THERE AT ALL.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Not a stale row. &lt;strong&gt;No row.&lt;/strong&gt; The same sequence under write-through returns the address, because the durable write happened before the user was ever told yes.&lt;/p&gt;

&lt;p&gt;That is the whole failure at the top of this article: an ordinary deploy, inside the durability window, and a confirmed change that never existed.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why anyone accepts that
&lt;/h2&gt;

&lt;p&gt;Because of &lt;strong&gt;coalescing&lt;/strong&gt;. A hot key updated 900 times does not need 900 durable writes — the dirty map keeps only the newest value, so the flush writes it once:&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;one-at-a-time&lt;/th&gt;
&lt;th&gt;batched behind a dirty map&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;database writes for 900 updates to one key&lt;/td&gt;
&lt;td&gt;900&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wall clock&lt;/td&gt;
&lt;td&gt;5276.8 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;13.3 ms&lt;/strong&gt; (396× faster)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a cart quantity, a session, a counter, a &lt;code&gt;last_seen&lt;/code&gt; timestamp, that is an enormous win and the risk is genuinely acceptable — nobody is harmed if five seconds of "last seen" evaporate. For a confirmed delivery address or a payment record, the same trade is indefensible.&lt;/p&gt;


&lt;h2&gt;
  
  
  The durability window is a number you choose
&lt;/h2&gt;

&lt;p&gt;Write-through's answer is "zero milliseconds." Write-back's answer is "however long until the next flush" — and if you cannot say that number out loud, you have not chosen it, you have inherited it.&lt;/p&gt;

&lt;p&gt;Two guards make write-back safe to actually run:&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;MAX_DIRTY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_address&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;addr&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dirty&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_DIRTY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# 1. bound the queue
&lt;/span&gt;        &lt;span class="nf"&gt;flush_now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                    &lt;span class="c1"&gt;#    backpressure, never unbounded RAM
&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;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dirty&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;addr&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flush_lag_seconds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;               &lt;span class="c1"&gt;# 2. measure the real window, live
&lt;/span&gt;    &lt;span class="k"&gt;return&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;last_successful_flush_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The bound stops a flusher that has fallen behind from turning into an out-of-memory kill that takes every unflushed write with it. The metric is the one number to put on a dashboard and alert on: it &lt;em&gt;is&lt;/em&gt; your data-loss exposure, in seconds, right now.&lt;/p&gt;


&lt;h2&gt;
  
  
  Write-around: the third strategy
&lt;/h2&gt;

&lt;p&gt;Write-around writes the database and skips the cache entirely:&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;import_row&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;addr&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 orders SET addr = ? 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;addr&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&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;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# invalidate, do not populate
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It exists for writes that nobody is going to read soon — a nightly import, a bulk backfill. Populating the cache with a million rows that no user will request just evicts the keys people &lt;em&gt;are&lt;/em&gt; reading, and your hit rate falls off a cliff right after the import finishes.&lt;/p&gt;


&lt;h2&gt;
  
  
  The same decision in LLM serving
&lt;/h2&gt;

&lt;p&gt;This is not a legacy-backend concern. Every inference stack in production runs the same three strategies under different names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;semantic cache&lt;/strong&gt; in front of a model is cache-aside on the read. If you write a corrected answer or a moderation verdict into it and defer the durable write, that is write-back — and a pod restart loses the corrections you believe you shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV-cache and session state&lt;/strong&gt; for a multi-turn conversation are the textbook write-back case: enormous update rates on one key, coalescing wins, and nobody is harmed by losing the last few seconds of an ephemeral attention state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage and token accounting&lt;/strong&gt;, on the other hand, is money. Batch it for throughput if you must, but the flush lag on that queue is the number of billable tokens you are willing to lose. That belongs on a dashboard, not in a comment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule transfers exactly: batch what is cheap to lose, commit what is expensive to lose, and know your window.&lt;/p&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Strategy&lt;/th&gt;
      &lt;th&gt;What happens on a write&lt;/th&gt;
      &lt;th&gt;Data-loss window&lt;/th&gt;
      &lt;th&gt;When to use&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Write-through&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Durable commit first (~6 ms), then the cache&lt;/td&gt;
      &lt;td&gt;
&lt;strong&gt;Zero&lt;/strong&gt; — the answer is true when you say it&lt;/td&gt;
      &lt;td&gt;Anything a user is told succeeded: addresses, orders, payments, permissions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Write-back&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Cache + dirty map, acknowledge in ~1 µs, flush in batches&lt;/td&gt;
      &lt;td&gt;Up to the flush interval — seconds of confirmed data&lt;/td&gt;
      &lt;td&gt;High-rate, low-value state: counters, sessions, carts, last-seen&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Write-around&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Durable commit, cache invalidated and not populated&lt;/td&gt;
      &lt;td&gt;Zero (durability); costs a read miss&lt;/td&gt;
      &lt;td&gt;Bulk imports and backfills nobody will read back soon&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Nothing (accidental)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Database updated, cache untouched&lt;/td&gt;
      &lt;td&gt;Zero loss, but unbounded &lt;em&gt;staleness&lt;/em&gt; until TTL&lt;/td&gt;
      &lt;td&gt;Never on purpose — this is the bug, not a strategy&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The whole thing in one breath
&lt;/h2&gt;

&lt;p&gt;A cache is a second copy, so every write has to decide which copy becomes true first. Write-through pays a durable ~6 ms on every change and can never lose a confirmed write. Write-back acknowledges in microseconds and coalesces 900 updates into one database write, but for the length of its flush interval the only copy of a confirmed fact lives in RAM on one machine — and an ordinary restart in that window deletes a change your user watched succeed. Write-around keeps bulk writes from evicting your hot keys. Bound the dirty queue, export the flush lag, and you have turned an invisible risk into a number.&lt;/p&gt;




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

&lt;p&gt;Default to &lt;strong&gt;write-through&lt;/strong&gt;. It is boring, it costs about six milliseconds, and "saved" means saved.&lt;/p&gt;

&lt;p&gt;Reach for &lt;strong&gt;write-back&lt;/strong&gt; deliberately, for a specific hot key whose last few seconds are genuinely disposable — and only with a bounded dirty map and a flush-lag metric on a dashboard.&lt;/p&gt;

&lt;p&gt;The question is never "is my cache fast." It is: &lt;strong&gt;how many milliseconds of confirmed data am I willing to lose?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Watch the &lt;a href="https://youtu.be/7rsWn-AYUUQ" rel="noopener noreferrer"&gt;full 11-minute walkthrough&lt;/a&gt; for all of the code and the loss reproduced live, or the &lt;a href="https://youtu.be/Tv7pRBhaysQ" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; for the short of it.&lt;/p&gt;

</description>
      <category>caching</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Airflow vs Cron: When a Crontab Line Isn't Enough</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 06 Aug 2026 07:51:28 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/airflow-vs-cron-when-a-crontab-line-isnt-enough-3418</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/airflow-vs-cron-when-a-crontab-line-isnt-enough-3418</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/TDR8EhZbOtE" 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/airflow-vs-cron-when-a-crontab-line-isnt-enough?id=148" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Just use cron" and "we need Airflow" are both wrong about half the time.&lt;/p&gt;

&lt;p&gt;They look like the same tool — both run your jobs on a schedule — so teams pick one out of habit and pay for it later. But scheduling isn't the real difference. The real difference is whether your work is &lt;strong&gt;one command on a clock&lt;/strong&gt; or &lt;strong&gt;a graph of tasks that depend on each other&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The one-line mental model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cron&lt;/strong&gt; asks &lt;em&gt;"is it time yet?"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airflow&lt;/strong&gt; asks &lt;em&gt;"what's the state of my whole pipeline?"&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  cron: one line, one clock
&lt;/h2&gt;

&lt;p&gt;cron is a time-based scheduler that has shipped on essentially every Unix box for about forty years. You give it a time expression and a command. That's the entire interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# min hour dom mon dow   command
0    2    *   *   *      /opt/etl/run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 02:00 every day, cron runs the script. There is no daemon to operate, no database to back up, no dashboard to log into. It is one of the most battle-tested pieces of software you will ever depend on, and for a single timed job nothing is simpler or more reliable.&lt;/p&gt;

&lt;p&gt;The important thing to notice is what cron &lt;em&gt;knows&lt;/em&gt;: the clock, and nothing else. It does not know what your command does, whether it succeeded, or whether anything else depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the crontab starts to hurt
&lt;/h2&gt;

&lt;p&gt;The failure mode shows up the moment you have a second step. Say &lt;code&gt;load&lt;/code&gt; must run after &lt;code&gt;extract&lt;/code&gt;. cron has no way to express "after", so you do the thing everyone does — you guess a gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0    2    *   *   *      /opt/etl/extract.sh
10   2    *   *   *      /opt/etl/load.sh    # extract "should" be done by now
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works right up until the night &lt;code&gt;extract&lt;/code&gt; runs long, or crashes. cron does not care. At 02:10 it fires &lt;code&gt;load&lt;/code&gt; exactly on schedule — against stale data, partial data, or no data at all. And you will not find out from cron, because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No dependency awareness.&lt;/strong&gt; 02:10 is a wish, not a guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retries.&lt;/strong&gt; A transient network blip means the run is simply lost until tomorrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backfill.&lt;/strong&gt; The box was down for two days? Those two days never happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No observability.&lt;/strong&gt; "Did it run?" is answered by SSH-ing in and grepping logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bugs. cron does exactly what it promised. You just asked it a question it was never designed to answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Airflow: the pipeline is the unit
&lt;/h2&gt;

&lt;p&gt;Airflow attacks the other question. Instead of scheduling &lt;em&gt;commands&lt;/em&gt;, you describe your work as a &lt;strong&gt;DAG&lt;/strong&gt; — a directed acyclic graph of tasks, where the edges are dependencies:&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;airflow.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="nd"&gt;@dag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0 2 * * *&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;catchup&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;def&lt;/span&gt; &lt;span class="nf"&gt;daily_etl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;

    &lt;span class="nd"&gt;@task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&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;extract&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;fetch_rows&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@task&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;transform&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;clean&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="nd"&gt;@task&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;load&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="nf"&gt;write_warehouse&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="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;

&lt;span class="nf"&gt;daily_etl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line is the whole point. &lt;code&gt;load&lt;/code&gt; does not run at a &lt;em&gt;time&lt;/em&gt; — it runs when &lt;code&gt;transform&lt;/code&gt; has &lt;strong&gt;succeeded&lt;/strong&gt;, which runs when &lt;code&gt;extract&lt;/code&gt; has succeeded. The schedule only starts the graph; the graph decides the rest.&lt;/p&gt;

&lt;p&gt;That single change buys you the entire list cron was missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dependencies&lt;/strong&gt; are declared, not approximated with a ten-minute gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries&lt;/strong&gt; are a keyword argument (&lt;code&gt;retries=3&lt;/code&gt;), applied per task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfills&lt;/strong&gt; are first-class: &lt;code&gt;catchup=True&lt;/code&gt; and Airflow will run the windows it missed, because each run is tied to a data interval rather than to "now".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt; is a web UI showing every run, every task, every log, colour-coded green and red.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  And the bill for that
&lt;/h2&gt;

&lt;p&gt;Airflow is not a binary you drop in &lt;code&gt;/usr/local/bin&lt;/code&gt;. It is a system you now operate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;scheduler&lt;/strong&gt; process that must stay up,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;metadata database&lt;/strong&gt; (Postgres) holding every task state,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;queue&lt;/strong&gt; (Redis/Celery) or a Kubernetes executor,&lt;/li&gt;
&lt;li&gt;a pool of &lt;strong&gt;workers&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;and the upgrades, migrations and on-call that come with all of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To fire one nightly &lt;code&gt;python etl.py&lt;/code&gt;, that is pure overhead — you have replaced a line of text with a distributed system, and now the scheduler itself can page you at 3 a.m. The joke that writes itself: you cannot run Airflow to make sure Airflow is running.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Dimension&lt;/th&gt;
      &lt;th&gt;cron&lt;/th&gt;
      &lt;th&gt;Apache Airflow&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Core question&lt;/td&gt;
      &lt;td&gt;"Is it time yet?"&lt;/td&gt;
      &lt;td&gt;"What's the state of my pipeline?"&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Unit of work&lt;/td&gt;
      &lt;td&gt;One command&lt;/td&gt;
      &lt;td&gt;A DAG of tasks&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Dependencies&lt;/td&gt;
      &lt;td&gt;None — approximated with time gaps&lt;/td&gt;
      &lt;td&gt;Declared in the graph&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Retries&lt;/td&gt;
      &lt;td&gt;Whatever you hand-roll in the script&lt;/td&gt;
      &lt;td&gt;Built in, per task&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Missed runs / backfill&lt;/td&gt;
      &lt;td&gt;Lost forever&lt;/td&gt;
      &lt;td&gt;Backfilled by data interval&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Observability&lt;/td&gt;
      &lt;td&gt;grep the logs over SSH&lt;/td&gt;
      &lt;td&gt;Web UI, run history, per-task logs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Operational cost&lt;/td&gt;
      &lt;td&gt;Zero — already installed&lt;/td&gt;
      &lt;td&gt;Scheduler + Postgres + queue + workers&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Scale-out&lt;/td&gt;
      &lt;td&gt;One box&lt;/td&gt;
      &lt;td&gt;Worker pool / Kubernetes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Best fit&lt;/td&gt;
      &lt;td&gt;A single timed job&lt;/td&gt;
      &lt;td&gt;A multi-step pipeline with state&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The same split shows up in LLM pipelines
&lt;/h2&gt;

&lt;p&gt;If you are building AI systems rather than classic ETL, don't assume this is a data-engineering-only question — the orchestration lane is where most RAG systems quietly rot.&lt;/p&gt;

&lt;p&gt;A retrieval pipeline is a DAG whether you admit it or not: &lt;strong&gt;fetch sources → chunk → embed → upsert into the vector store → rebuild the index → smoke-test retrieval&lt;/strong&gt;. Every one of those steps depends on the one before it, and two properties of LLM workloads make cron a particularly bad fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model APIs are flaky and rate-limited.&lt;/strong&gt; An embedding job over 200k chunks will hit a 429 or a timeout at some point. With Airflow that's &lt;code&gt;retries=5&lt;/code&gt; plus exponential backoff on one task; with cron the whole nightly script dies and you find out when answers go stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-embedding is a backfill problem.&lt;/strong&gt; Switch embedding models, change your chunk size, or fix a parser bug, and you need to re-run the pipeline over a &lt;em&gt;historical range&lt;/em&gt; of documents. That is exactly what a backfill is — and cron has no concept of a past window at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure is also nastier than a missed report. If &lt;code&gt;embed&lt;/code&gt; fails and &lt;code&gt;upsert&lt;/code&gt; runs anyway on a partial batch, you don't get an error — you get a vector store that is silently missing a slice of your corpus, and a chatbot that confidently answers "I don't have information about that." Partial state in a retrieval index looks exactly like a working system.&lt;/p&gt;

&lt;p&gt;The heuristic carries over cleanly: a nightly "re-embed the docs folder" script is fine on cron. A multi-source ingestion pipeline feeding a production RAG endpoint — with evaluation gates and index swaps — wants a real orchestrator.&lt;/p&gt;




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

&lt;p&gt;Reach for &lt;strong&gt;cron&lt;/strong&gt; when the work is genuinely one command on a clock: a nightly backup, a certificate renewal, a single script that is idempotent and cheap to re-run. Adding an orchestrator there buys you nothing and hands you a scheduler, a database and a queue to babysit.&lt;/p&gt;

&lt;p&gt;Reach for &lt;strong&gt;Airflow&lt;/strong&gt; (or Dagster, or Prefect — the model is the same) the moment step B needs step A to have &lt;em&gt;succeeded&lt;/em&gt;, or you need retries, backfills, and a straight answer to "what ran last night?". The tell is simple: &lt;strong&gt;the first time you write a crontab line whose correctness depends on guessing how long the previous line takes, you have outgrown cron.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One command on a clock, or a graph of tasks with state. Match the tool to the shape of the work — not to what's trendy.&lt;/p&gt;

&lt;p&gt;Watch the &lt;a href="https://youtu.be/TDR8EhZbOtE" rel="noopener noreferrer"&gt;2-minute version&lt;/a&gt; for the whole comparison in one pass.&lt;/p&gt;

</description>
      <category>airflow</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>devops</category>
    </item>
    <item>
      <title>25 Years of Object Detection in 4 Walls (Sliding Window YOLO DETR)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Wed, 05 Aug 2026 08:29:31 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/25-years-of-object-detection-in-4-walls-sliding-window-yolo-detr-980</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/25-years-of-object-detection-in-4-walls-sliding-window-yolo-detr-980</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/U8oEiwQpvzI" 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/25-years-of-object-detection-in-4-walls-sliding-window-yolo-detr?id=147" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A detector must emit a list of unknown length. A classifier cannot—one picture in, one label out, a fixed shape. This mismatch drove twenty-five years of object detection, and the field hit it four times in four different ways.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model&lt;/strong&gt;: Each breakthrough solved a hard constraint the previous era exposed, then revealed a new one. The real pivot wasn't architecture—it was the loss function, and whether you remove duplicates before or after training starts.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Wall 1: The Sliding Window (1990s–2011)
&lt;/h2&gt;

&lt;p&gt;You cannot slide a classifier everywhere—it's too slow and it says WHAT, not WHERE. So the early field asked the easy question: "Is there an object in this 32×32 patch?" Then asked it everywhere. A 640×640 image yields roughly 145,000 overlapping windows. For each one, humans hand-drew features (edges, histograms, gradients). Each window ran through a trained classifier. Each detection got a confidence score.&lt;/p&gt;

&lt;p&gt;The cost was computational. The ceiling was accuracy: hand-drawn features lose to learned ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wall 2: Learning Features, Keeping Regions (2012–2014)
&lt;/h2&gt;

&lt;p&gt;2012: AlexNet proved a neural network could learn better features than humans could design. ImageNet error dropped from ~26% to ~15%. But AlexNet is still a classifier—it sees one 224×224 image, emits one label.&lt;/p&gt;

&lt;p&gt;R-CNN's insight (2014): Use a region proposal algorithm to narrow the search space from 145,000 windows to ~2,000 candidate regions, then run AlexNet on each one. The regions came from selective search (a hand-tuned algorithm). The mAP on PASCAL VOC 2012 jumped from 35.1 to 53.3.&lt;/p&gt;

&lt;p&gt;The cost was speed: two stages, two forward passes per region. The ceiling was latency—video and real-time inference stayed out of reach.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wall 3: One Stage, One Forward Pass—and a Hard Trade-off (2015–2019)
&lt;/h2&gt;

&lt;p&gt;YOLO (2015) solved speed: one forward pass, one output tensor. No region proposals. No selective search.&lt;/p&gt;

&lt;p&gt;But here is what nobody says about one-stage detectors: &lt;strong&gt;they do not emit a list&lt;/strong&gt;. They emit a fixed grid of candidate boxes. A modern YOLO at 640×640 resolution emits roughly 8,400 candidate boxes—always. Before it sees the image. The shape is baked into the architecture.&lt;/p&gt;

&lt;p&gt;Most of those boxes describe the same dog. To filter them down, every detector for twenty years ended the same way: &lt;strong&gt;non-maximum suppression (NMS)&lt;/strong&gt;. Greedy loop:&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;# Pseudocode: NMS is not learned, not differentiable
&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;highest_confidence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keep&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;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;boxes&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;iou&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="n"&gt;b&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;iou_threshold&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Hand-picked threshold
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NMS has three fatal flaws:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is not learned. No gradient flows through it during training.&lt;/li&gt;
&lt;li&gt;The IoU threshold is a human-picked number, retuned per dataset.&lt;/li&gt;
&lt;li&gt;In a crowd, it deletes real people, because real people overlap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;YOLO v1–v5 and their descendants (SSD, RetinaNet, EfficientDet) all paid this bill. Faster inference came at the cost of a post-processing hack that was brittle and blind to the actual training signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wall 4: Predict a Set, Not a Grid (2020–Present)
&lt;/h2&gt;

&lt;p&gt;DETR (Detection Transformer, 2020) reframed the problem: &lt;strong&gt;you want a set out, so predict a set&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;100 learned query slots. Each slot outputs one bounding box and one class label (or the special class "no object"). Always 100 queries. Always a hundred outputs.&lt;/p&gt;

&lt;p&gt;The variable-length problem from wall one is gone—you have a fixed shape. But now you have a different problem: how do you train it? You have 100 predictions and maybe 3 real objects. Which prediction should match which object?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The breakthrough was the loss function, not the architecture.&lt;/strong&gt; DETR uses the Hungarian algorithm:&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;# Pseudocode: Hungarian matching in the loss, not post-processing
&lt;/span&gt;&lt;span class="n"&gt;cost_matrix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_costs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;predictions_100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ground_truth_3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 100 × 3
&lt;/span&gt;&lt;span class="n"&gt;best_matching&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hungarian_algorithm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost_matrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# one-to-one pairing
&lt;/span&gt;
&lt;span class="c1"&gt;# Train the 3 matched predictions to be correct.
# Train the 97 unmatched predictions to output "no object".
# A second query that also finds the dog is punished during training.
# Duplicates are never created.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hand-picked IoU threshold. No greedy loop. No post-processing that ignores the loss. Duplicates do not get removed—they are never trained to exist.&lt;/p&gt;

&lt;p&gt;The cost was high: DETR needed 500 epochs to converge (versus ~100 for YOLO), and small objects were harder to detect. The next four years of research paid down both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where It Landed (Mid-2026)
&lt;/h2&gt;

&lt;p&gt;By mid-2026, both families had converged on the same architectural shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One-stage detector (no separate region proposal network).&lt;/li&gt;
&lt;li&gt;No hand-tuned anchors.&lt;/li&gt;
&lt;li&gt;No NMS in the default pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;YOLO26 ships NMS-free by default, using a Decoupled Head with soft spatial and class matching in the loss (inspired by DETR). RF-DETR (real-time DETR) is a transformer detector with architectural tricks (RoI queries, token pruning) to match YOLO's inference speed.&lt;/p&gt;

&lt;p&gt;On the COCO benchmark, they tie:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model&lt;/th&gt;
      &lt;th&gt;COCO mAP&lt;/th&gt;
      &lt;th&gt;Parameters&lt;/th&gt;
      &lt;th&gt;Approach&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;YOLO26x&lt;/td&gt;
      &lt;td&gt;57.5&lt;/td&gt;
      &lt;td&gt;55.7M&lt;/td&gt;
      &lt;td&gt;Grid-based, learned matching in loss&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;RF-DETR-L&lt;/td&gt;
      &lt;td&gt;56.5&lt;/td&gt;
      &lt;td&gt;129M&lt;/td&gt;
      &lt;td&gt;Set-based, Hungarian matching in loss&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What still differs is the &lt;strong&gt;backbone&lt;/strong&gt;: the feature extraction before the head. YOLO's backbone is trained on supervised detection data. RF-DETR's uses DINOv2, a self-supervised vision model trained on a billion unlabeled images. COCO is a tie because the head (grid vs. set) is now irrelevant—the bottleneck moved downstream.&lt;/p&gt;

&lt;p&gt;This is why COCO scores alone are the wrong lens to pick a detector. You have to ask: which backbone suits your data and your label budget?&lt;/p&gt;




&lt;h2&gt;
  
  
  For LLM Inference: Why This Matters to Serving
&lt;/h2&gt;

&lt;p&gt;If you serve detectors as part of a larger AI system (e.g., grounding for a vision-language model), the shift from NMS to learned matching changes latency profile:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;YOLO with NMS&lt;/strong&gt;: Inference time is deterministic. NMS is O(n log n) on the number of detections, but it runs after the network, so you cannot batch or fuse it with the next module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DETR / NMS-free&lt;/strong&gt;: All computation is in the neural network. Batch processing and quantization apply uniformly. No separate post-processing stage to stall on edge devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hungarian matching (training only)&lt;/strong&gt;: Only happens during training. At inference, you just pick the 100 predictions; no matching step. But the 100 queries are learned to avoid duplicates, so you spend fewer cycles filtering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For real-time serving, prefer NMS-free detectors (YOLO26, RF-DETR) because they fuse into quantized execution graphs and avoid the post-processing bottleneck.&lt;/p&gt;




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

&lt;p&gt;Reach for YOLO when you need simplicity and real-time speed on single-backbone setups; reach for DETR (or RF-DETR) when you want to plug in a better backbone (like DINOv2) and have latency headroom for transformer compute. The head stopped mattering in 2024. The backbone and your label budget are what differ now.&lt;/p&gt;

&lt;p&gt;Watch the &lt;a href="https://youtu.be/o25FcCgHv90" rel="noopener noreferrer"&gt;17-minute deep dive&lt;/a&gt; for the full chain, the numbers, and the trade-offs.&lt;/p&gt;

</description>
      <category>computervision</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Ollama vs vLLM: Local Dev Tool or Production Inference Engine?</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Fri, 31 Jul 2026 16:35:46 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/ollama-vs-vllm-local-dev-tool-or-production-inference-engine-2mf3</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/ollama-vs-vllm-local-dev-tool-or-production-inference-engine-2mf3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/ZbskLBUV-Wg" 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/ollama-vs-vllm-local-dev-tool-or-production-inference-engine?id=145" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ollama and vLLM both let you "run a large language model locally." Ask either one to serve 200 concurrent users on a single GPU, and the resemblance ends.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model:&lt;/strong&gt; Ollama is a single-user dev tool that trades throughput for simplicity; vLLM is a production inference server that trades setup complexity for multi-user concurrency.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Ollama Does
&lt;/h2&gt;

&lt;p&gt;Ollama wraps &lt;code&gt;llama.cpp&lt;/code&gt; and quantized GGUF weights into a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run llama3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No Python environment to configure, no CUDA dependencies to compile, no batch-size tuning. The model downloads, loads, and starts answering you in seconds on your laptop's GPU. Ollama is optimized for &lt;em&gt;developer friction&lt;/em&gt;—the fastest possible path from zero to "the model is running and I can ask it a question."&lt;/p&gt;

&lt;p&gt;Under the hood, Ollama serves requests &lt;em&gt;largely one at a time&lt;/em&gt;. When fifty concurrent API calls arrive, they don't run in parallel. They queue. Latency climbs. The GPU idles waiting for responses. The tool that "just works" for a solo developer becomes a bottleneck the moment real traffic hits.&lt;/p&gt;




&lt;h2&gt;
  
  
  What vLLM Does
&lt;/h2&gt;

&lt;p&gt;vLLM is built around two architectural ideas that flip Ollama's trade-off:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PagedAttention&lt;/strong&gt; manages GPU memory like an operating system pages RAM. Instead of allocating one contiguous block per request (wasting memory when sequences are short), vLLM divides the KV cache into fixed-size pages and reuses them across requests. One GPU holds many more concurrent requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous batching&lt;/strong&gt; slots new requests into the running batch &lt;em&gt;mid-flight&lt;/em&gt; instead of waiting for a batch to finish. Request A doesn't have to complete before Request B starts generating tokens. They generate in parallel, packed efficiently into the same GPU compute.&lt;/p&gt;

&lt;p&gt;The result: one GPU serving hundreds of concurrent requests, exposed through an OpenAI-compatible API:&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;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_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;token-unused&lt;/span&gt;&lt;span class="sh"&gt;"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-2-7b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Explain inference optimization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost of that throughput: you must provide a real GPU with real VRAM, install Python and CUDA dependencies, and configure the server deliberately—model path, tensor parallelism, GPU memory fraction, batch size. Even asking one solo question on your laptop means paying the full production setup tax.&lt;/p&gt;




&lt;h2&gt;
  
  
  They're Not Competitors—Different Axes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;tbody&gt;
&lt;tr&gt;
    &lt;th&gt;&lt;/th&gt;
    &lt;th&gt;Ollama&lt;/th&gt;
    &lt;th&gt;vLLM&lt;/th&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;Primary User&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;One developer, local machine&lt;/td&gt;
    &lt;td&gt;Production API serving N concurrent users&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;Request Handling&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;Largely sequential (queue)&lt;/td&gt;
    &lt;td&gt;Continuous batching (parallel)&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;GPU Memory Model&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;Fixed allocation per request&lt;/td&gt;
    &lt;td&gt;PagedAttention (page-based)&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;Setup Friction&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;One command, zero config&lt;/td&gt;
    &lt;td&gt;Python, CUDA, deliberate tuning&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;Concurrent Requests per GPU&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;~1–5&lt;/td&gt;
    &lt;td&gt;~50–500+&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;Best For&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;Local prototyping, tinkering&lt;/td&gt;
    &lt;td&gt;Production inference APIs&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;&lt;strong&gt;If You Pick Wrong&lt;/strong&gt;&lt;/td&gt;
    &lt;td&gt;Production traffic queues, GPU underutilized&lt;/td&gt;
    &lt;td&gt;Local experiments pay ops overhead&lt;/td&gt;
  &lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Inference Lens
&lt;/h2&gt;

&lt;p&gt;In LLM serving, throughput depends on how you handle two latencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTFT&lt;/strong&gt; (time to first token): latency before generation starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPOT&lt;/strong&gt; (time per output token): latency per token generated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ollama's sequential queue inflates both. A second request waits for the first to finish generating all its tokens before the GPU even &lt;em&gt;touches&lt;/em&gt; the prompt. vLLM's continuous batching decouples them: Request B's prompt can be processed while Request A is still generating output tokens, and both requests' output generation runs in parallel on the same GPU. One GPU's TPOT is divided among N concurrent requests.&lt;/p&gt;




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

&lt;p&gt;Reach for &lt;strong&gt;Ollama&lt;/strong&gt; when you're prototyping alone—you want model inference in one command with zero fuss. Reach for &lt;strong&gt;vLLM&lt;/strong&gt; when you're serving real traffic—you need the GPU utilization, latency scaling, and concurrency that continuous batching and PagedAttention provide.&lt;/p&gt;

&lt;p&gt;Don't use Ollama for production. Don't use vLLM for local tinkering.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;You're serving one fine-tuned model to 200 concurrent API users on a single GPU. Ollama or vLLM—and what specifically breaks if you pick wrong?&lt;/p&gt;

&lt;p&gt;Watch the 90-second reel for a visual walkthrough of this breakdown.&lt;/p&gt;

</description>
      <category>ollamavsvllm</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Autoscaling Explained: Why New Servers Always Arrive Two Minutes Too Late</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Fri, 31 Jul 2026 06:37:02 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/autoscaling-explained-why-new-servers-always-arrive-two-minutes-too-late-30m7</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/autoscaling-explained-why-new-servers-always-arrive-two-minutes-too-late-30m7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/SUakSRRReA0" 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/autoscaling-explained-why-new-servers-always-arrive-two-minutes-too-late?id=144" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your dashboard says autoscaling worked. Your users say the site was down. Both are right.&lt;/p&gt;

&lt;p&gt;That contradiction is not a misconfiguration and it is not your fault. It falls straight out of how the mechanism works. Autoscaling is a &lt;strong&gt;reaction, not a prediction&lt;/strong&gt; — and the reaction is structurally late by exactly one boot cycle.&lt;/p&gt;

&lt;p&gt;Let's build the whole loop from first principles on one running example: &lt;strong&gt;LunchRun&lt;/strong&gt;, an office lunch-ordering site. Everything below is cloud-agnostic — no orchestrator, no vendor product names. Every provider implements the same four moving parts.&lt;/p&gt;




&lt;h2&gt;
  
  
  First principles: nothing "senses" that you are busy
&lt;/h2&gt;

&lt;p&gt;There is no magic in autoscaling. No component feels strain. A machine gets added for exactly one reason:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Something counted a number, compared it to a line you drew, and the number crossed the line.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. Everything else is detail about &lt;em&gt;which&lt;/em&gt; number, &lt;em&gt;where&lt;/em&gt; the line is, and &lt;em&gt;how long&lt;/em&gt; it takes to act. And it is that last part — the latency — where all the pain lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — something counts a number
&lt;/h2&gt;

&lt;p&gt;A control loop samples a metric on a fixed interval, typically about &lt;strong&gt;every 60 seconds&lt;/strong&gt;. The default metric almost everywhere is &lt;strong&gt;average CPU across the group&lt;/strong&gt;. It is popular because it is free and universal, not because it is good.&lt;/p&gt;

&lt;p&gt;CPU only &lt;em&gt;guesses&lt;/em&gt; at pain. A machine can sit at 45% CPU while every request waits 4 seconds on a saturated database connection pool. CPU says "fine". Users say "broken".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Metric&lt;/th&gt;
      &lt;th&gt;What it actually measures&lt;/th&gt;
      &lt;th&gt;Fails when&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Average CPU&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;How busy the processor is&lt;/td&gt;
      &lt;td&gt;The bottleneck is I/O, locks, or a connection pool — CPU stays low while latency explodes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Request rate&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Incoming traffic volume&lt;/td&gt;
      &lt;td&gt;Requests get more expensive; the same rate now means twice the work&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Queue depth&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;How many requests are &lt;em&gt;waiting&lt;/em&gt;
&lt;/td&gt;
      &lt;td&gt;Rarely — a growing queue &lt;em&gt;is&lt;/em&gt; the pain, not a proxy for it&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;p95 latency&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;What users experience&lt;/td&gt;
      &lt;td&gt;It is a lagging signal — by the time it moves, you are already behind&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you can export it, &lt;strong&gt;scale on queue depth&lt;/strong&gt;. The number of requests piling up in front of your workers is the closest thing to a direct measurement of "we do not have enough capacity right now."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — you draw the line
&lt;/h2&gt;

&lt;p&gt;The rule is a threshold plus a confirmation window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF  average CPU &amp;gt; 70%
FOR 2 consecutive samples   (≈ 2 minutes)
THEN launch 1 instance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two-sample requirement is not padding. One sample over the line is a blip — a garbage collection pause, a backup job, a bot burst. Acting on a single sample gives you a scaler that thrashes on noise. So you deliberately buy stability with &lt;strong&gt;60 more seconds of delay&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then there are the bounds, and &lt;strong&gt;both of them cost you&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minimum too low.&lt;/strong&gt; LunchRun idles on 2 machines overnight to save money. At 11:55 the rush starts. The scaler has to climb from 2, one boot cycle at a time — and the spike is over before help finishes arriving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maximum too high.&lt;/strong&gt; A retry storm or one runaway bot looks exactly like real demand. The group happily scales to 40 machines and hands you the bill. The ceiling is not a performance setting, it is a &lt;strong&gt;blast radius&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The heart of it: launched is not serving
&lt;/h2&gt;

&lt;p&gt;This is the part that no marketing page states plainly. The moment the scaler decides to act, the clock does &lt;strong&gt;not&lt;/strong&gt; stop. A newly launched machine is not capacity. It is a promise of capacity.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Stage&lt;/th&gt;
      &lt;th&gt;What happens&lt;/th&gt;
      &lt;th&gt;Typical cost&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Boot&lt;/td&gt;
      &lt;td&gt;Hardware allocated, kernel and OS come up, network attaches&lt;/td&gt;
      &lt;td&gt;~30s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Image pull&lt;/td&gt;
      &lt;td&gt;Your container image is downloaded and unpacked&lt;/td&gt;
      &lt;td&gt;~25s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;App start + warm&lt;/td&gt;
      &lt;td&gt;Runtime starts, config loads, connection pools and caches fill&lt;/td&gt;
      &lt;td&gt;~20s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Health checks&lt;/td&gt;
      &lt;td&gt;Two consecutive passes before it is trusted&lt;/td&gt;
      &lt;td&gt;~15s&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;Only now does the load balancer route it one request&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~90s&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those numbers is a lever. A 1.2 GB image pulls slower than a 200 MB one. An app that eagerly warms a 50-connection pool starts slower than one that lazily opens connections. Two health checks 15 seconds apart are safer &lt;em&gt;and&lt;/em&gt; slower than one. You are not stuck with 90 seconds — but you are never at zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;Now add up the whole chain, from the first user who felt slowness to the first request served by new capacity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 60s   notice   — the sample interval; the spike began just after a sample
 60s   confirm  — the second consecutive breach
 90s   boot     — launch → serving
─────
~210s  ≈ 3.5 minutes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Those three and a half minutes are carried entirely by the machines you already had.&lt;/strong&gt; Not partially. Entirely. During the gap your existing group absorbs 100% of the load, and whatever it cannot absorb is queued, slowed, or dropped.&lt;/p&gt;

&lt;p&gt;For LunchRun that is the whole lunch rush. Orders climb, latency climbs, timeouts start, and at 12:03 the new machines finally go healthy — right as the traffic curve flattens on its own. The autoscaling graph afterwards looks &lt;em&gt;perfect&lt;/em&gt;: demand rose, capacity rose, both came down. It just did so several minutes after it mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling back down: the flapping trap
&lt;/h2&gt;

&lt;p&gt;Scale-in has its own failure mode, and it is a loop.&lt;/p&gt;

&lt;p&gt;Traffic drops. The scaler removes a machine. But the remaining machines now split the same work between fewer of them, so &lt;strong&gt;average CPU rises&lt;/strong&gt; — possibly straight back over the scale-out threshold. The scaler adds a machine. CPU falls. It removes one. That is &lt;strong&gt;flapping&lt;/strong&gt;: paying for boot cycles all afternoon while capacity oscillates.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;cooldown&lt;/strong&gt; — after any scaling action, ignore the metric entirely for a few minutes and let the system settle before judging it again. Scale-in is normally made deliberately more conservative than scale-out: quick to add, slow to remove. Removing capacity you turn out to need costs a full boot cycle to undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consequence: a reaction, not a prediction
&lt;/h2&gt;

&lt;p&gt;Put the pieces together and the conclusion is structural, not situational:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autoscaling is always late by exactly one boot cycle.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which means it is genuinely good at one thing and useless at another:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;The ramp.&lt;/strong&gt; Traffic that builds over 10–20 minutes — a workday warming up, a campaign spreading, a gradual regional shift. The loop tracks it comfortably and you barely notice it working.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;The spike.&lt;/strong&gt; Ten times the traffic in 20 seconds. A push notification to 500,000 phones. A link hitting the front page. A thundering herd of clients all retrying at the same instant. &lt;strong&gt;That load is served entirely by the capacity you already had&lt;/strong&gt;, because the reaction has not finished happening yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Just autoscale it" was never an answer to a thundering herd.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually saves you sits in front of the scaler
&lt;/h2&gt;

&lt;p&gt;If the scaler is structurally late, the absorption has to come from somewhere else. Three things, and none of them are scaling rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Headroom.&lt;/strong&gt; Run the fleet at ~50% utilization, not 90%. That spare capacity is not waste — it is &lt;strong&gt;a boot cycle in the bank&lt;/strong&gt;, prepaid. It is the only thing that can respond in zero seconds because it is already booted, already warm, already receiving traffic. Headroom is the single highest-leverage knob here, and it is an explicit money-for-safety trade you should make on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A queue.&lt;/strong&gt; Put a buffer between accepting work and doing work. LunchRun takes the order immediately and confirms it; the kitchen works through the backlog at its own pace. The queue converts a &lt;strong&gt;failure&lt;/strong&gt; into a &lt;strong&gt;delay&lt;/strong&gt;, and slow beats failed by an enormous margin. It also gives you the best scaling metric you will ever have, for free: the queue's own depth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pre-warming on a schedule.&lt;/strong&gt; The most under-used technique in the list. Lunch is at 12:00 every single day. Scale up at 11:45 because you &lt;em&gt;know&lt;/em&gt;, not because a metric noticed. Any spike you can predict — business hours, a scheduled campaign, a Friday deploy, a known batch job — should never be handled reactively at all. This is the only mechanism in the whole toolkit that is genuinely a prediction.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Autoscaling handles the ramp. The buffer in front handles the spike.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The same math, with a much worse constant: autoscaling model serving
&lt;/h2&gt;

&lt;p&gt;Everything above gets sharper the moment the thing you are scaling is an inference service, because the boot ladder gets dramatically longer.&lt;/p&gt;

&lt;p&gt;A stateless web app pulls a 200 MB image. An LLM replica has to pull an image &lt;strong&gt;and&lt;/strong&gt; load tens of gigabytes of weights into GPU memory, allocate a KV cache, and often run a compile or graph-capture warmup pass before the first token comes out at a sane speed. The "launched is not serving" stage stops being ~90 seconds and becomes &lt;strong&gt;2–10 minutes&lt;/strong&gt;. The gap does not shrink — it multiplies.&lt;/p&gt;

&lt;p&gt;The metric choice matters more too. GPU utilization is an even worse proxy than CPU: a server can look 90% utilized while running one wasteful batch, or 40% utilized while requests wait. The signals that actually track pain in a serving stack are the queue-shaped ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pending request / queue depth&lt;/strong&gt; — the same principle as above, and the standard scaling signal for inference gateways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first token (TTFT)&lt;/strong&gt; — climbs as soon as requests start waiting for a batch slot, which is exactly the early warning you want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time per output token (TPOT)&lt;/strong&gt; — degrades when batches get too large, telling you the opposite story: you are past the good part of the throughput curve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the mitigations map one-for-one. &lt;strong&gt;Headroom&lt;/strong&gt; becomes keeping spare replicas or spare KV-cache blocks. &lt;strong&gt;The queue&lt;/strong&gt; becomes continuous batching plus an admission queue, which is why a well-run inference server degrades into "everyone waits a bit longer" instead of "everyone gets a 503". &lt;strong&gt;Pre-warming&lt;/strong&gt; becomes keeping a warm pool of loaded replicas for known traffic patterns — the entire reason serverless GPU platforms sell "keep N warm" as a headline feature. It is the identical structural problem, just with a boot cycle long enough that nobody can pretend the reactive loop will save them.&lt;/p&gt;

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

&lt;p&gt;Autoscaling is a &lt;strong&gt;control loop&lt;/strong&gt;, and every control loop has a response time. Yours is roughly: &lt;em&gt;sample interval + confirmation window + boot time&lt;/em&gt;. Write that number down for your own system — it is usually between two and five minutes, and most teams have never measured it.&lt;/p&gt;

&lt;p&gt;Then design around it honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scale on queue depth&lt;/strong&gt; if you possibly can. CPU guesses at pain; a queue &lt;em&gt;is&lt;/em&gt; the pain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink the boot ladder&lt;/strong&gt; — smaller images, faster startup, lazier warmup, tuned health checks. Every second cut is a second the gap shrinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set the maximum as a blast radius&lt;/strong&gt;, not as an ambition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give scale-in a cooldown&lt;/strong&gt;, or you will pay to watch capacity oscillate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buy headroom on purpose&lt;/strong&gt;, and stop treating 50% utilization as waste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-warm everything you can predict&lt;/strong&gt; — that is the only part of your system that isn't reacting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Autoscaling is not there to save you from a spike. It never was. It is there so the ramp doesn't require a human, and so the buffer you built in front of it never has to stretch further than one boot cycle.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/SUakSRRReA0" rel="noopener noreferrer"&gt;Autoscaling — why it is always ~2 minutes too late&lt;/a&gt; · Follow along on &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>devops</category>
    </item>
    <item>
      <title>Debouncing vs Throttling: Only the Last Event, or the Ones in Between?</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:34:47 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/debouncing-vs-throttling-only-the-last-event-or-the-ones-in-between-4ien</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/debouncing-vs-throttling-only-the-last-event-or-the-ones-in-between-4ien</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/Bn1kR-wg9GI" 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/debouncing-vs-throttling-only-the-last-event-or-the-ones-in-between?id=143" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ten keystrokes in a search box fire ten &lt;code&gt;input&lt;/code&gt; events — and ten API calls, nine of them wasted before the tenth even lands.&lt;/p&gt;

&lt;p&gt;That's the bug everyone has written once. The fix everyone reaches for is "just debounce it," and in a search box that fix is correct. Which is exactly why so many people think debounce is the answer to &lt;em&gt;every&lt;/em&gt; noisy event stream. It isn't.&lt;/p&gt;

&lt;p&gt;The real question is never "how do I fire fewer events?" The event itself is free — it's a function call in the browser's event loop. What isn't free is the &lt;strong&gt;work behind&lt;/strong&gt; it: the network round trip, the layout recalculation, the write to disk. So you need a &lt;strong&gt;rule for which events become work&lt;/strong&gt;. There are two rules, and they disagree.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debounce&lt;/strong&gt; waits for the stream to go quiet, then fires &lt;strong&gt;once&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle&lt;/strong&gt; lets one through, then ignores the rest until a fixed interval has elapsed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither one is a timer that runs on its own. No events, no calls.&lt;/p&gt;




&lt;h2&gt;
  
  
  Debounce: wait for silence
&lt;/h2&gt;

&lt;p&gt;Every incoming event &lt;strong&gt;cancels&lt;/strong&gt; the pending callback and restarts the clock. The callback only survives to run if nothing interrupts it for the full delay.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// cancel whatever was pending&lt;/span&gt;
    &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// restart the clock&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole mechanism — six lines, &lt;code&gt;clearTimeout&lt;/code&gt; and &lt;code&gt;setTimeout&lt;/code&gt;. Type ten characters quickly and you get &lt;strong&gt;one&lt;/strong&gt; request, carrying the final query. Perfect, because in a search box only the last keystroke is real. The nine intermediate queries were never answers anyone wanted.&lt;/p&gt;

&lt;h3&gt;
  
  
  What debounce costs you
&lt;/h3&gt;

&lt;p&gt;Move the same code to a drag handler and the trade-off flips into view. Drag a slider and you get exactly one call — &lt;strong&gt;after you let go&lt;/strong&gt;. Which means that for the entire duration of the drag, &lt;em&gt;nothing happens&lt;/em&gt;. No preview, no live value, no progress. The UI looks frozen, and users read frozen as broken.&lt;/p&gt;

&lt;p&gt;Then there's the failure mode that most explanations skip entirely:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If the events never stop, the timer never survives to fire.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Debounce guarantees a call after silence. It guarantees &lt;strong&gt;nothing&lt;/strong&gt; if silence never arrives. A user typing steadily for 60 seconds without a single 300 ms pause triggers &lt;strong&gt;zero&lt;/strong&gt; saves. Wrap autosave in a plain &lt;code&gt;debounce(save, 300)&lt;/code&gt; and you have shipped a feature that, under exactly the conditions it exists for — sustained writing — saves nothing at all.&lt;/p&gt;

&lt;p&gt;Debounce can starve forever. Throttle cannot.&lt;/p&gt;




&lt;h2&gt;
  
  
  Throttle: watch the clock, not the silence
&lt;/h2&gt;

&lt;p&gt;Throttle ignores whether the stream is quiet. It keeps a timestamp and asks one question per event: &lt;em&gt;has the interval elapsed?&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;interval&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;last&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;     &lt;span class="c1"&gt;// window open?&lt;/span&gt;
      &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// else: drop this event on the floor&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onDrag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updatePosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&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;Same 60 events a second, but now the handler fires &lt;strong&gt;steadily throughout&lt;/strong&gt; the drag — it tracks your finger. Progress is guaranteed regardless of how long the input lasts, because the clock advances whether or not the user pauses.&lt;/p&gt;

&lt;h3&gt;
  
  
  What throttle costs you
&lt;/h3&gt;

&lt;p&gt;The naive timestamp gate above is &lt;strong&gt;leading-edge only&lt;/strong&gt;: it runs on the first event of each window and drops everything after. So the &lt;em&gt;final&lt;/em&gt; event of the stream — the one that carries where the user actually stopped — arrives inside a window that's already closed, and gets dropped with the rest.&lt;/p&gt;

&lt;p&gt;You end up storing a position that is &lt;strong&gt;one step stale, permanently&lt;/strong&gt;. The slider snapped to 47 on screen, your backend thinks 43. That's why every real throttle implementation adds a &lt;strong&gt;trailing call&lt;/strong&gt;: when the window closes, fire once more with the most recent arguments if any were dropped.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side by side
&lt;/h2&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;Debounce&lt;/th&gt;
      &lt;th&gt;Throttle&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Trigger&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Silence — N ms with no new event&lt;/td&gt;
      &lt;td&gt;The clock — one call per N ms&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Mechanism&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;
&lt;code&gt;clearTimeout&lt;/code&gt; + &lt;code&gt;setTimeout&lt;/code&gt; on every event&lt;/td&gt;
      &lt;td&gt;Timestamp gate (&lt;code&gt;Date.now() - last &amp;gt;= interval&lt;/code&gt;)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Calls during a 5s burst&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;0 (then 1 at the end)&lt;/td&gt;
      &lt;td&gt;~5s / interval, spread evenly&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Guarantees progress?&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;No — can starve under continuous input&lt;/td&gt;
      &lt;td&gt;Yes — fires regardless of pauses&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Sees the final event?&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Yes, always — that's the point&lt;/td&gt;
      &lt;td&gt;Only with a trailing call bolted on&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;Frozen UI mid-stream; zero calls if input never pauses&lt;/td&gt;
      &lt;td&gt;Permanently one step stale (leading-edge only)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Reach for it when&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Autocomplete, validate-on-stop, resize settle&lt;/td&gt;
      &lt;td&gt;Drag, scroll, autosave, progress, telemetry&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The rule worth memorising
&lt;/h2&gt;

&lt;p&gt;Every framework, every language, same rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Debounce = only the LAST event matters.&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Throttle = the ones in between matter too.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ask that question about your handler and the choice answers itself. Autocomplete? Only the final query is a real query — debounce. Drag preview? Every intermediate position is a frame the user is watching — throttle. Autosave? The intermediate states genuinely matter &lt;em&gt;and&lt;/em&gt; the stream may never pause — throttle, or debounce with a &lt;code&gt;maxWait&lt;/code&gt; so it can't starve.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;maxWait&lt;/code&gt; option in lodash's &lt;code&gt;debounce&lt;/code&gt; isn't decoration. It's the escape hatch for exactly the autosave bug above: fire after 300 ms of silence, &lt;strong&gt;but never go longer than 5 s without firing&lt;/strong&gt;, whatever the user does.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same two rules, one layer up: LLM and AI apps
&lt;/h2&gt;

&lt;p&gt;This isn't a frontend-only pattern. The moment your app calls a model, the "event is free, the work behind it isn't" asymmetry gets &lt;em&gt;much&lt;/em&gt; sharper — an &lt;code&gt;input&lt;/code&gt; event costs nothing, an embedding call plus a vector search plus a generation costs money and hundreds of milliseconds.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debounce the query, not the keystroke.&lt;/strong&gt; RAG-backed search-as-you-type should never embed on every character. Debounce at 250–400 ms so one embedding + retrieval fires per &lt;em&gt;thought&lt;/em&gt;, not per letter. Only the last query is a real query — textbook debounce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle the token stream into the UI.&lt;/strong&gt; A streaming response arrives as many small chunks per second. Re-rendering markdown on every token burns main-thread time for frames nobody perceives. Throttle the render to ~30–60 ms — the intermediate tokens matter (that's the whole point of streaming), just not all of them. &lt;strong&gt;And you must add the trailing call&lt;/strong&gt;, or the last chunk lands in a closed window and the answer renders permanently truncated by a few words. This is the leading-edge bug, shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle outbound calls to respect provider rate limits.&lt;/strong&gt; A token-bucket throttle in front of the provider client is what keeps a burst of agent tool-calls under the requests-per-minute ceiling. Debounce would be actively wrong here — it would collapse a queue of &lt;em&gt;distinct, all-necessary&lt;/em&gt; calls into one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debounce-with-maxWait for agent state checkpoints.&lt;/strong&gt; Persisting conversation or agent scratchpad state on every step is wasteful; debouncing it alone risks a long-running agent that never pauses and therefore never checkpoints — the autosave starvation bug, now with an hour of lost work behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same two questions, bigger blast radius: &lt;em&gt;does only the final one matter&lt;/em&gt;, and &lt;em&gt;can this stream ever fail to pause?&lt;/em&gt;&lt;/p&gt;




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

&lt;p&gt;Debounce and throttle are not competing implementations of one idea — they encode two different beliefs about your event stream.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;debounce&lt;/strong&gt; when intermediate events are noise on the way to a real one: autocomplete, validation on stop, resize settle, search-as-you-type embeddings.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;throttle&lt;/strong&gt; when intermediate events are themselves the product, or when the stream might never go quiet: drag, scroll, progress, telemetry, streaming-token rendering, rate-limited API calls.&lt;/p&gt;

&lt;p&gt;And whichever you pick, handle its cost explicitly — a &lt;code&gt;maxWait&lt;/code&gt; on debounce so it can't starve, a trailing call on throttle so it can't go stale. Both are six lines of plain &lt;code&gt;setTimeout&lt;/code&gt; and &lt;code&gt;Date.now()&lt;/code&gt;. Reaching for the lodash import before you can write either one by hand is how the autosave bug ships.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Watch the reel:&lt;/strong&gt; &lt;a href="https://youtu.be/Bn1kR-wg9GI" rel="noopener noreferrer"&gt;Debouncing vs Throttling in 100 seconds&lt;/a&gt; — the drag-handler test and both failure modes, animated.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>programming</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
