<?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: HappyBug</title>
    <description>The latest articles on DEV Community by HappyBug (@happybug).</description>
    <link>https://dev.to/happybug</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%2F2882753%2Fc03df9af-b748-41fd-86be-15300a11cd3e.jpeg</url>
      <title>DEV Community: HappyBug</title>
      <link>https://dev.to/happybug</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/happybug"/>
    <language>en</language>
    <item>
      <title>System Design the Agentic Way — Phase 3: Data Storage Trade-offs</title>
      <dc:creator>HappyBug</dc:creator>
      <pubDate>Sun, 26 Jul 2026 11:18:31 +0000</pubDate>
      <link>https://dev.to/happybug/system-design-the-agentic-way-phase-3-data-storage-trade-offs-2ob7</link>
      <guid>https://dev.to/happybug/system-design-the-agentic-way-phase-3-data-storage-trade-offs-2ob7</guid>
      <description>&lt;h2&gt;
  
  
  Phase 3: Data Storage Trade-offs
&lt;/h2&gt;

&lt;p&gt;Phase 2 leaned on a shared store and waved at durability — "the data survived a Redis restart" — then admitted the caveat: a graceful shutdown snapshotted to disk, an ungraceful one wouldn't have. Phase 3 goes inside that store and asks the question the caveat was hiding: when you tell a client &lt;em&gt;saved&lt;/em&gt;, what exactly did you promise, and what does keeping that promise cost?&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Question&lt;/li&gt;
&lt;li&gt;
The Building Blocks: What "Saved" Actually Costs

&lt;ul&gt;
&lt;li&gt;Where a Write Actually Goes: The Four Layers&lt;/li&gt;
&lt;li&gt;write() vs fsync and the fsync Tax&lt;/li&gt;
&lt;li&gt;Process Crash vs Power Loss&lt;/li&gt;
&lt;li&gt;The Durability Window&lt;/li&gt;
&lt;li&gt;The Write-Ahead Log&lt;/li&gt;
&lt;li&gt;Indexes and Write Amplification&lt;/li&gt;
&lt;li&gt;B-tree vs LSM: The Storage-Engine Fork&lt;/li&gt;
&lt;li&gt;The Trade-off Matrix&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Durability Is Not Availability to Reads&lt;/li&gt;
&lt;li&gt;The Fix Reveals the Lesson&lt;/li&gt;
&lt;li&gt;What This Phase Actually Teaches&lt;/li&gt;
&lt;li&gt;Where I'm Still Fuzzy&lt;/li&gt;
&lt;li&gt;Try It Yourself&lt;/li&gt;
&lt;li&gt;Next&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Question
&lt;/h2&gt;

&lt;p&gt;Here's a question that sounds trivial until you try to answer it precisely: &lt;strong&gt;when your database replies &lt;code&gt;200 OK&lt;/code&gt;, where is your data at that exact moment?&lt;/strong&gt; On the disk? In memory? Somewhere in between — a place a power cut would quietly erase a half-second later? Most engineers can't say, and the ones who found out the hard way have a war story about it.&lt;/p&gt;

&lt;p&gt;Chase that question and it forks into three: &lt;em&gt;How do I not lose data on a crash? How do I make writes fast? How do I make reads fast?&lt;/em&gt; Every storage engine on Earth is just a particular answer to those three — and they pull against each other, so no engine wins all three. The tension between them is the entire phase, and by the end you'll be able to answer the opening question for any system, including the one you build here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    DURABILITY
              (don't lose acked writes)
                        /\
                       /  \
                      / .  \        every storage engine
                     /  .   \       lives somewhere inside
                    /   .    \      this triangle — push
                   /    .     \     toward one corner and
                  / .   .    . \    you give up the others
                 /______.______\
          WRITE SPEED         READ SPEED
        (cheap appends)     (indexes, sorted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A war story sets the stakes. An analytics SaaS, Series B, ran an ingestion service that accepted event writes, buffered them in memory, and flushed to disk every 200 ms in a batch — because "fewer, bigger writes are faster." The client got a &lt;code&gt;200 OK&lt;/code&gt; the instant the write hit the in-memory buffer. Everyone was proud of the p99.&lt;/p&gt;

&lt;p&gt;Then a routine node recycle during autoscaling sent &lt;code&gt;SIGKILL&lt;/code&gt; to a pod holding ~200 ms of un-flushed writes. At 3:47 AM no alert fired; the service came back healthy. Days later a customer proved from their own logs that ~4,000 events they'd received a &lt;code&gt;200 OK&lt;/code&gt; for simply weren't in any report. The team blamed the query layer, then the cache, then a "customer clock issue." The truth was simpler: the writes were &lt;strong&gt;acknowledged before they were durable&lt;/strong&gt;. A crash inside the 200 ms window was invisible data loss — no error at write time, no error at read time, just missing records.&lt;/p&gt;

&lt;p&gt;The lesson is a single question — &lt;em&gt;when you tell a client "saved," what have you actually promised?&lt;/em&gt; — and the rest of this article is what it takes to make that promise honest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Building Blocks: What "Saved" Actually Costs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where a Write Actually Goes: The Four Layers
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;write()&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; put data on disk. It passes through four layers, and only the last one is non-volatile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your string
   │  write()               microseconds
   ▼
① App buffer      (process RAM)          dies on power loss AND on process crash
   ▼
② OS page cache   (kernel RAM, dirty)    dies on power loss — SURVIVES a process crash
   ▼
③ Disk controller cache (RAM on the SSD) dies on power loss
   ▼
④ NAND / platter  (non-volatile media)   survives everything  ← the only durable layer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Volatile?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① App buffer&lt;/td&gt;
&lt;td&gt;Process RAM&lt;/td&gt;
&lt;td&gt;dies on power loss (and process crash)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② OS page cache&lt;/td&gt;
&lt;td&gt;Kernel RAM (dirty pages)&lt;/td&gt;
&lt;td&gt;dies on power loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ Disk controller cache&lt;/td&gt;
&lt;td&gt;RAM on the SSD&lt;/td&gt;
&lt;td&gt;dies on power loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;④ &lt;strong&gt;NAND / platter&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Non-volatile media&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;survives&lt;/strong&gt; ← durable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;NAND&lt;/strong&gt; is the array of non-volatile flash chips inside an SSD (the HDD equivalent is a magnetic platter). Getting bytes from layer ① to layer ④ is the whole job. Everything above ④ is a form of RAM that is lying to you about durability until proven otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  write() vs fsync and the fsync Tax
&lt;/h3&gt;

&lt;p&gt;Two syscalls, two very different guarantees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;write()&lt;/code&gt; returns → your bytes are in the &lt;strong&gt;OS page cache (②)&lt;/strong&gt;. They survive a &lt;em&gt;process&lt;/em&gt; crash, because the kernel outlives the process. They do &lt;strong&gt;not&lt;/strong&gt; survive power loss.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fsync(fd)&lt;/code&gt; returns → the kernel has flushed those pages down to &lt;strong&gt;NAND (④)&lt;/strong&gt; and blocked until the disk confirmed. Now the write survives everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gap between them is the price of durability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;write()   ≈ microseconds        (just a memcpy into kernel RAM)
fsync()   ≈ 0.1–2 ms  on SSD    (100–1000× slower)
          ≈ 5–15 ms  on HDD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 100–1000× gap is the &lt;strong&gt;fsync tax&lt;/strong&gt;. Skipping &lt;code&gt;fsync&lt;/code&gt; makes writes look dramatically faster — which is exactly why teams skip it, and exactly how they end up acking data they haven't actually saved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Process Crash vs Power Loss
&lt;/h3&gt;

&lt;p&gt;This is the crux the analytics team missed, so it's worth being precise. The two failure classes have &lt;em&gt;opposite&lt;/em&gt; consequences for un-&lt;code&gt;fsync&lt;/code&gt;'d data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Process crash  → kill -9 / OOM / deploy / pod eviction
                 kernel survives → page cache (②) survives → un-fsync'd data ✅ SURVIVES

Power loss     → power event / --force stop / spot reclaim / host or kernel failure
                 RAM dies → page cache (②) is gone → un-fsync'd data ❌ LOST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is subtle: a &lt;em&gt;process&lt;/em&gt; crash — &lt;code&gt;kill -9&lt;/code&gt;, an OOM, a deploy — leaves the page cache alive, so un-&lt;code&gt;fsync&lt;/code&gt;'d data quietly survives it. Only a power-class event — a real power loss, a kernel panic, a forced host stop — erases that RAM. Durability has to hold against the second class, and the happy path never exercises it, which is why the loss tends to surface weeks later in someone else's logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Durability Window
&lt;/h3&gt;

&lt;p&gt;If you &lt;code&gt;fsync&lt;/code&gt; on a timer instead of on every write, there is a slice of acknowledged data that isn't yet on disk — the &lt;strong&gt;durability window&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;fsync policy&lt;/th&gt;
&lt;th&gt;Window of exposure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appendfsync no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;seconds (whenever the OS feels like flushing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appendfsync everysec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;≈ 1 s — Redis's default; the fsync timer runs independent of your write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appendfsync always&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;≈ 0 — one fsync per write, maximum safety, maximum tax&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The subtle mistake here: "fsync every second" is &lt;strong&gt;not&lt;/strong&gt; "my write is synced within a second of &lt;em&gt;my write&lt;/em&gt;." The timer fires on its own schedule; a crash can land in the instant right before the next tick, taking a nearly-full second of acked writes with it. The window is a property of the timer, not of any individual request.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Write-Ahead Log
&lt;/h3&gt;

&lt;p&gt;The fix for all of this is the &lt;strong&gt;Write-Ahead Log (WAL)&lt;/strong&gt;: write the change to a durable, append-only log &lt;em&gt;first&lt;/em&gt; — ahead of applying it to the real data structure — and only ack once that log entry is on disk. Log first, apply second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SET balance=100
 ① append to WAL      (sequential, at the end of the file)
 ② fsync(wal_fd)      (one cheap flush → durable)
 ③ return 200 OK      (now it is safe to say "saved")
 ...later, in the background...
 ④ apply to the real data structure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why a log instead of just updating the data structure in place?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Append is cheap because it's sequential.&lt;/strong&gt; The write always goes to the end of one file — no seeking. A B-tree update, by contrast, touches scattered pages all over the disk; fsyncing those is slow &lt;em&gt;and&lt;/em&gt; a crash mid-update leaves a half-written structure = garbage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery is a replay.&lt;/strong&gt; On restart you read the log and re-apply entries &lt;strong&gt;in order&lt;/strong&gt;. An incomplete tail entry (a crash mid-append) is detected by length/checksum and discarded — it was never acked, so dropping it is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never write in the middle.&lt;/strong&gt; The log is append-only on purpose: it preserves order (the history &lt;em&gt;is&lt;/em&gt; the source of truth), keeps writes sequential, and keeps already-durable bytes immutable so only the final entry can ever be torn. You reclaim space with a checkpoint/truncation or a compaction that writes a fresh file and deletes the old one — never an in-place edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An analogy makes the ordering guarantee concrete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Analogy:   a bank ledger — you never erase an old line; you append a correcting entry.
Technical: SET x=1 then SET x=2 both append. On power loss, replay in order → GET x = 2.
           Last write wins because log order is the source of truth.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern is not a toy — it is everywhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Its WAL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;the WAL (&lt;code&gt;pg_wal&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;WAL journal mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RocksDB / LevelDB&lt;/td&gt;
&lt;td&gt;the write-ahead log in front of the memtable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka&lt;/td&gt;
&lt;td&gt;the entire log &lt;em&gt;is&lt;/em&gt; the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;AOF (Append-Only File) — the AOF &lt;strong&gt;is&lt;/strong&gt; a WAL; &lt;code&gt;appendfsync always&lt;/code&gt; = window ≈ 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One optimization worth naming: &lt;strong&gt;group commit.&lt;/strong&gt; Doing one &lt;code&gt;fsync&lt;/code&gt; per write caps throughput at N fsyncs/sec. Instead, batch every write that arrives in a 1–10 ms window, append them all, do &lt;strong&gt;one&lt;/strong&gt; fsync for the batch, then ack all of them. Each client waits a hair longer; total throughput jumps, because the expensive syscall is amortized across the batch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Indexes and Write Amplification
&lt;/h3&gt;

&lt;p&gt;An append-only log is wonderful to write and terrible to read: finding a key means a &lt;strong&gt;full scan&lt;/strong&gt; — read every record, O(n).&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;index&lt;/strong&gt; is a second, sorted data structure that answers "where is key X?", turning that O(n) scan into O(log n) or O(1). It's a derived copy kept in sync with the data — like the index at the back of a textbook. It is &lt;em&gt;not&lt;/em&gt; the source of truth.&lt;/p&gt;

&lt;p&gt;But indexes aren't free, and the cost has a name: &lt;strong&gt;write amplification&lt;/strong&gt; — the ratio of physical writes to logical writes. Every index has to be updated on every write, so five indexes mean roughly five times the disk I/O per logical write.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Analogy:   the "Index Everything" reflex — index every column "just in case".
Technical: 5 indexes ≈ 5× write I/O. The signal you fell for it:
           your writes are slower than your reads.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An index is a &lt;strong&gt;trade&lt;/strong&gt; — read speed bought with write cost and disk space — not a free win. That single reframing is what separates "add an index" as a reflex from "add an index" as a decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  B-tree vs LSM: The Storage-Engine Fork
&lt;/h3&gt;

&lt;p&gt;Once you accept that write amplification is real, the whole storage-engine design splits into two families based on how they lay writes down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  B-tree — UPDATE IN PLACE              LSM — APPEND, MERGE LATER

  write ─┐                              write ─┐
         ▼                                     ▼
   seek to the right page              memtable  (RAM, sorted)
         ▼                                     │ fills up
   modify it where it lives                    ▼
         ▼                              flush ─► SSTable  (immutable file)
   maybe split / rebalance                     │  SSTable
                                               │  SSTable
  → random I/O, scattered writes               ▼
  → great reads (one lookup)          compaction merges them
                                       → sequential I/O, never in place
                                       → high write throughput
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;B-tree&lt;/strong&gt; (Postgres/MySQL)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;LSM&lt;/strong&gt; (Cassandra/RocksDB)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write path&lt;/td&gt;
&lt;td&gt;random, &lt;strong&gt;in-place&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;sequential append&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write throughput&lt;/td&gt;
&lt;td&gt;moderate&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read path&lt;/td&gt;
&lt;td&gt;one lookup down the tree&lt;/td&gt;
&lt;td&gt;memtable + several SSTables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read amplification&lt;/td&gt;
&lt;td&gt;low (~1)&lt;/td&gt;
&lt;td&gt;higher (N files)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background work&lt;/td&gt;
&lt;td&gt;page splits / rebalancing&lt;/td&gt;
&lt;td&gt;compaction (CPU/I/O bursts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best when&lt;/td&gt;
&lt;td&gt;balanced/read-heavy, rich queries + txns&lt;/td&gt;
&lt;td&gt;write-heavy (logs, metrics, events)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An &lt;strong&gt;LSM tree&lt;/strong&gt; is, in effect, "the WAL idea applied to the whole database." Its parts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;memtable   → in-RAM sorted table; every write lands here first (fast, sequential)
SSTable    → Sorted String Table: an immutable sorted file flushed from the memtable
compaction → background merge of SSTables; drops overwritten/deleted keys,
             writes new files and deletes the old ones — never in-place
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision falls out of the workload. For a metrics system doing millions of writes/second where reads are mostly for recent data, you choose &lt;strong&gt;LSM&lt;/strong&gt;: writes become cheap sequential appends, and recent reads hit the memtable and the newest SSTables so read amplification barely bites. A B-tree is ruled out at that write rate by random-I/O seeks and write amplification from page rewrites and splits. This is exactly why Cassandra, ScyllaDB, and InfluxDB are LSM-based.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Trade-off Matrix
&lt;/h3&gt;

&lt;p&gt;Widen the lens to include the append-only log as a first-class storage choice, and the full picture is three columns:&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;B-tree (Postgres)&lt;/th&gt;
&lt;th&gt;LSM (Cassandra)&lt;/th&gt;
&lt;th&gt;Append-only log (Kafka)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Write throughput&lt;/td&gt;
&lt;td&gt;moderate&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;td&gt;highest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point lookup&lt;/td&gt;
&lt;td&gt;excellent&lt;/td&gt;
&lt;td&gt;good&lt;/td&gt;
&lt;td&gt;poor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Range/sorted scan&lt;/td&gt;
&lt;td&gt;excellent&lt;/td&gt;
&lt;td&gt;good&lt;/td&gt;
&lt;td&gt;poor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write amplification&lt;/td&gt;
&lt;td&gt;page rewrites + splits&lt;/td&gt;
&lt;td&gt;compaction (amortized)&lt;/td&gt;
&lt;td&gt;~none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read amplification&lt;/td&gt;
&lt;td&gt;~1&lt;/td&gt;
&lt;td&gt;N (worst on old data)&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational cost&lt;/td&gt;
&lt;td&gt;mature, predictable&lt;/td&gt;
&lt;td&gt;compaction tuning&lt;/td&gt;
&lt;td&gt;consumers track offsets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reach for it when&lt;/td&gt;
&lt;td&gt;balanced/read-heavy + txns&lt;/td&gt;
&lt;td&gt;write-heavy, key/time-series&lt;/td&gt;
&lt;td&gt;event streams, ordered replay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;How to read it:&lt;/strong&gt; don't pick the "best" engine — pick the column whose &lt;em&gt;strength&lt;/em&gt; matches your bottleneck and whose &lt;em&gt;weakness&lt;/em&gt; you can tolerate. Real systems mix engines: Postgres for accounts, Cassandra for the firehose, Kafka to move between them. "One database for everything" is the trap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Durability Is Not Availability to Reads
&lt;/h2&gt;

&lt;p&gt;The prototype is a durable key-value store: an append-only &lt;code&gt;wal.log&lt;/code&gt; as the source of truth, an in-memory &lt;code&gt;Map&lt;/code&gt; as a derived read cache rebuilt on startup. The write path is log-first, and the durability contract lives in one function — the ack happens &lt;em&gt;only after&lt;/em&gt; the fsync returns:&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;appendToWAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;logEntry&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;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;logEntry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Reaches only the OS page cache (layer ②) so far.&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Force page cache → NAND (④), and BLOCK until the disk confirms.&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsyncSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// We return only AFTER the fsync. The caller can now honestly say "OK".&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Building the write path first exposed a gap that the phrase "the data survived" always hides. After appending two writes and restarting the server, &lt;code&gt;GET x&lt;/code&gt; returned &lt;strong&gt;404&lt;/strong&gt; — while &lt;code&gt;wal.log&lt;/code&gt; still held both entries on disk.&lt;/p&gt;

&lt;p&gt;Nothing was lost. The WAL faithfully kept every write across the restart. But the thing that &lt;em&gt;answers&lt;/em&gt; a &lt;code&gt;GET&lt;/code&gt; is the in-memory &lt;code&gt;Map&lt;/code&gt;, and a fresh process starts with an empty one (&lt;code&gt;in-memory keys at startup: 0&lt;/code&gt;). The history was recorded and never replayed. &lt;strong&gt;Durability is not availability to reads&lt;/strong&gt; — they are two independent properties, and the WAL pattern has two halves to match: &lt;em&gt;append on write&lt;/em&gt; (done) and &lt;em&gt;replay on recovery&lt;/em&gt; (missing).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  AFTER A RESTART, BEFORE recovery runs:

    wal.log (disk)   │  x=1   y=hello        ← durable, intact, still on disk
    Map    (memory)  │  (empty)              ← GET x → 404
                     └────────────────────────────────────────────
                        the data IS saved, but nothing can read it

  recover()  replays the log back into memory:

    wal.log ──► parse each line in order ──► apply() ──► Map
      x=1                                                x=1
      y=hello                                            y=hello
                     └────────────────────────────────────────────
                        now GET x → 200 OK (value 1). gap closed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second half is &lt;code&gt;recover()&lt;/code&gt;, called before the server accepts any request:&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;recover&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;WAL_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// clean file ends in "\n" → last element is ""&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;applied&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="nx"&gt;discardedTail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &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;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;          &lt;span class="c1"&gt;// same apply() the live write path uses&lt;/span&gt;
      &lt;span class="nx"&gt;applied&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Only the LAST line may be broken — a torn tail append that was never acked.&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;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;discardedTail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// safe to drop&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`WAL corruption at line &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// real corruption → fail loud&lt;/span&gt;
    &lt;span class="p"&gt;}&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;applied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;discardedTail&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details carry the design. First, &lt;code&gt;apply()&lt;/code&gt; is shared by both the live &lt;code&gt;set&lt;/code&gt;/&lt;code&gt;del&lt;/code&gt; path and replay, which guarantees a write and its recovery are interpreted identically. Second, the torn-tail rule is asymmetric on purpose: a parse error on the &lt;strong&gt;last&lt;/strong&gt; non-empty line is a half-written append (never acked) and is discarded; a parse error &lt;strong&gt;anywhere else&lt;/strong&gt; is real corruption and throws loudly.&lt;/p&gt;

&lt;p&gt;With recovery in place, the same fresh process that returned 404 now serves &lt;code&gt;GET x = 1&lt;/code&gt; (HTTP 200) — &lt;code&gt;recovered 2 WAL entries → 2 keys&lt;/code&gt;. And last-write-wins holds across a restart: &lt;code&gt;PUT x=1&lt;/code&gt; then &lt;code&gt;PUT x=2&lt;/code&gt; appends a &lt;em&gt;new&lt;/em&gt; line rather than editing in place, so a restart replays &lt;code&gt;recovered 3 WAL entries → 2 keys&lt;/code&gt; and &lt;code&gt;GET x = 2&lt;/code&gt;. Replay order is write order.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix Reveals the Lesson
&lt;/h2&gt;

&lt;p&gt;Every failure in this phase traces to the same root — a layer of RAM being mistaken for durable storage — and each has a matching fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ack before durable&lt;/strong&gt; → append to the WAL and &lt;code&gt;fsync&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; returning &lt;code&gt;200 OK&lt;/code&gt;. The ack is only honest once the fsync confirms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data on disk but reads 404&lt;/strong&gt; → replay the WAL into the read structure on startup. Durability and read-availability are separate; you must build both halves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffering in app memory&lt;/strong&gt; → get bytes out of layer ① as early as possible; a per-request flush trades latency for the elimination of a silent-loss window (or use group commit to claw the latency back).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape is the same as Phases 1 and 2: the component that fails determines both the symptom and the fix. What's new is that the failure is &lt;em&gt;invisible at both write time and read time&lt;/em&gt; — no error is ever returned. It surfaces only under a power-class failure that erases RAM, which the happy path never exercises — so the loss can reach production before anyone notices.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Phase Actually Teaches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A &lt;code&gt;write()&lt;/code&gt; is not durable; only &lt;code&gt;fsync&lt;/code&gt; is.&lt;/strong&gt; Bytes pass through four layers and only NAND survives power loss. Until fsync returns, "saved" is a claim about RAM, not disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Process crash and power loss are different failure classes.&lt;/strong&gt; Un-&lt;code&gt;fsync&lt;/code&gt;'d data lives in the OS page cache — kernel memory — so it survives a process crash but dies on power loss. Conflating the two is how silent loss reaches production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A WAL makes "saved" honest, cheaply.&lt;/strong&gt; Append-first + fsync-before-ack buys durability with sequential I/O; recovery is an in-order replay; only the untorn, acked prefix is ever restored. It's the pattern under Postgres, Kafka, SQLite, and Redis AOF.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Durability is not availability to reads.&lt;/strong&gt; The log surviving a crash and the reads working after a crash are independent guarantees. You have to build append-on-write &lt;em&gt;and&lt;/em&gt; replay-on-recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Every index is a trade.&lt;/strong&gt; Read speed is bought with write amplification and space. "Index everything" shows up as writes slower than reads. B-tree vs LSM is the same trade at engine scale: pick the write/read amplification profile your workload can tolerate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision heuristic:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Must never lose an acked write            → WAL: append + fsync BEFORE ack
Throughput-bound by fsyncs/sec            → group commit (batch, one fsync, ack all)
Can tolerate a ~1s loss window for speed  → fsync on a timer (everysec), eyes open
Balanced/read-heavy + rich queries + txns → B-tree (Postgres/MySQL)
Write-heavy, key or time-series, recent reads → LSM (Cassandra/RocksDB/Influx)
Ordered event stream, replayable          → append-only log (Kafka)
Reads slower than a full scan can afford   → add ONE index, and only the ones you query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where I'm Still Fuzzy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Where the durability window should actually sit.&lt;/strong&gt; &lt;code&gt;everysec&lt;/code&gt; vs &lt;code&gt;always&lt;/code&gt; is a latency-vs-loss dial, but I don't have a principled way to choose the tolerance. Presumably it's a function of how much acked data the business can afford to lose per crash — but I haven't turned that into a number I'd defend.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Torn-write detection beyond a parse error.&lt;/strong&gt; Discarding a JSON line that won't parse catches an obviously truncated tail, but a torn write could in theory leave a line that &lt;em&gt;does&lt;/em&gt; parse yet is semantically wrong. Real engines use checksums per record. I understand why, but I haven't built the checksum path to feel where the naive length/parse check breaks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When group commit's added latency is unacceptable.&lt;/strong&gt; Batching amortizes fsync beautifully for throughput, but it deliberately makes each individual write wait. For a low-traffic, latency-sensitive write path the batch window might be pure overhead. I don't yet know where the crossover is.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;The prototype is at &lt;a href="https://github.com/aishwarya-chamanoor/system-design-agenticway" rel="noopener noreferrer"&gt;github.com/aishwarya-chamanoor/system-design-agenticway&lt;/a&gt; in &lt;code&gt;phase-03/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aishwarya-chamanoor/system-design-agenticway
&lt;span class="nb"&gt;cd &lt;/span&gt;system-design-agenticway/phase-03
node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Experiments worth running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;PUT /kv/x&lt;/code&gt; a value, restart the server, then &lt;code&gt;GET /kv/x&lt;/code&gt; — watch the startup log say &lt;code&gt;recovered N WAL entries&lt;/code&gt; and the read return your value. That's replay-on-recovery working.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT&lt;/code&gt; the same key twice with different values, restart, &lt;code&gt;GET&lt;/code&gt; — the second value wins, because the WAL appended a new line instead of editing in place. Observe last-write-wins falling out of log order.&lt;/li&gt;
&lt;li&gt;Peek at &lt;code&gt;wal.log&lt;/code&gt; between writes (it's newline-delimited JSON) and confirm it only ever grows — no line is ever rewritten. That append-only shape is the whole safety argument.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Phase 4 asks what happens when reads outrun the durable store this phase built — and what it costs to keep a faster copy of the truth without letting it drift.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of a 12-phase series building distributed systems intuition from first principles. Each phase has a running prototype, failure scenarios, and a gate check before moving on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>node</category>
      <category>architecture</category>
      <category>beginners</category>
    </item>
    <item>
      <title>System Design the Agentic Way — Phase 2: Stateless Horizontal Scaling</title>
      <dc:creator>HappyBug</dc:creator>
      <pubDate>Tue, 14 Jul 2026 11:09:21 +0000</pubDate>
      <link>https://dev.to/happybug/system-design-the-agentic-way-phase-2-stateless-horizontal-scaling-1gi5</link>
      <guid>https://dev.to/happybug/system-design-the-agentic-way-phase-2-stateless-horizontal-scaling-1gi5</guid>
      <description>&lt;h2&gt;
  
  
  Phase 2: Stateless Horizontal Scaling
&lt;/h2&gt;

&lt;p&gt;Phase 1 ended at a wall: a single machine has hard ceilings, and no amount of better code moves them. The obvious answer is "add more servers." Phase 2 is about everything that sentence quietly assumes — and the new failure modes that appear the moment a second machine exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Question&lt;/li&gt;
&lt;li&gt;
The Building Blocks: What "Add a Server" Actually Requires

&lt;ul&gt;
&lt;li&gt;State: The Thing That Breaks First&lt;/li&gt;
&lt;li&gt;The Shared-Nothing Principle&lt;/li&gt;
&lt;li&gt;Sticky Sessions: The Tempting Trap&lt;/li&gt;
&lt;li&gt;Load Balancing Algorithms&lt;/li&gt;
&lt;li&gt;Layer 4 vs Layer 7&lt;/li&gt;
&lt;li&gt;Latency vs Throughput&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Concurrent Requests Don't Run Sequentially&lt;/li&gt;
&lt;li&gt;What the Experiments Showed&lt;/li&gt;
&lt;li&gt;The Fix Reveals the Lesson&lt;/li&gt;
&lt;li&gt;What This Phase Actually Teaches&lt;/li&gt;
&lt;li&gt;Where I'm Still Fuzzy&lt;/li&gt;
&lt;li&gt;Try It Yourself&lt;/li&gt;
&lt;li&gt;Next&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Question
&lt;/h2&gt;

&lt;p&gt;You have one server and you want three. What has to be true about your application before adding the other two actually helps — and where does the bottleneck go once you do?&lt;/p&gt;

&lt;p&gt;Phase 2 is an argument that "add more servers" is not an infrastructure task. It's an application-architecture constraint that decides whether the extra machines do anything at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Building Blocks: What "Add a Server" Actually Requires
&lt;/h2&gt;

&lt;p&gt;A war story sets the stakes. A food delivery startup ran its entire API on one server. A routine Friday 6pm deploy meant an 8-second restart. During those 8 seconds: 47 lost payment confirmations, 312 connection resets, and — because the process had to reload a 2.3 GB &lt;code&gt;data.json&lt;/code&gt; — a 45-second full recovery. The bill was $23,000 in refunds.&lt;/p&gt;

&lt;p&gt;The root cause isn't the deploy. It's that a single machine can't be restarted without downtime and can't survive its own hardware dying. The fix is to run several identical copies. But copies only work if the servers hold no state — and that word hides most of the difficulty.&lt;/p&gt;

&lt;h3&gt;
  
  
  State: The Thing That Breaks First
&lt;/h3&gt;

&lt;p&gt;Take the Phase 1 server and imagine running three copies of it. Three distinct kinds of state each break in their own way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type of state&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Why 3 copies break it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Persistent data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;data.json&lt;/code&gt; on disk&lt;/td&gt;
&lt;td&gt;Each server has its own copy → writes diverge, reads disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;In-memory counters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;activeConnections&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Each server sees only its own slice → no global view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Coordination state&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;write queue / locks&lt;/td&gt;
&lt;td&gt;Only valid inside one process → no cross-server ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: anything a server remembers between requests becomes wrong the moment a second server exists, because the next request might land somewhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Shared-Nothing Principle
&lt;/h3&gt;

&lt;p&gt;The fix is to externalize &lt;em&gt;all&lt;/em&gt; state to a shared service — a database, a cache like Redis, a metrics store like Prometheus — so that no server owns anything. The server becomes a pure function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request in → (read shared state) → (write shared state) → response out
server remembers NOTHING between requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the &lt;strong&gt;shared-nothing&lt;/strong&gt; architecture. Any request can go to any server, because every server is interchangeable. That interchangeability is the entire point — it's what makes servers disposable, and disposable servers are what let you deploy, restart, and lose hardware without an outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sticky Sessions: The Tempting Trap
&lt;/h3&gt;

&lt;p&gt;If a server &lt;em&gt;does&lt;/em&gt; hold per-user state (a login session in memory), you can paper over it by pinning each user to the same server every time — a &lt;strong&gt;sticky session&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stateful  + sticky sessions → user X always routed to Server 2
Stateless + any routing     → user X can be served by any server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap: sticky sessions reintroduce the single-machine failure. When Server 2 dies, every user pinned to it loses their session — logged out, cart emptied — even though two other servers are healthy. Sticky sessions are a temporary crutch. The goal is always full statelessness, where a dying server costs nothing.&lt;/p&gt;

&lt;p&gt;A related temptation is to jump straight to microservices "to reduce blast radius." Scale the monolith horizontally &lt;em&gt;first&lt;/em&gt;: three identical copies, deployed one at a time (a rolling deploy), gives zero-downtime with zero architectural change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monolith, 1 server      → single point of failure
Monolith, 3 servers     → scales, survives restarts, still SIMPLE
Microservices, 20 svcs  → scales, but adds network failures + distributed transactions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decompose only when there's a specific reason to (that's Phase 9), not reflexively.&lt;/p&gt;

&lt;h3&gt;
  
  
  Load Balancing Algorithms
&lt;/h3&gt;

&lt;p&gt;With three interchangeable servers, something has to decide who gets each request. That's the load balancer, and the decision rule matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Weakness&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Round Robin&lt;/td&gt;
&lt;td&gt;A, B, C, A, B, C…&lt;/td&gt;
&lt;td&gt;Equal servers, equal-cost requests&lt;/td&gt;
&lt;td&gt;Ignores how heavy each request is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Least Connections&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Route to server with fewest active connections&lt;/td&gt;
&lt;td&gt;Mixed request durations&lt;/td&gt;
&lt;td&gt;Blind to raw server capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weighted Round Robin&lt;/td&gt;
&lt;td&gt;More traffic to beefier servers&lt;/td&gt;
&lt;td&gt;Heterogeneous hardware&lt;/td&gt;
&lt;td&gt;Static, doesn't adapt to load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random&lt;/td&gt;
&lt;td&gt;Pick any server&lt;/td&gt;
&lt;td&gt;Surprisingly good at scale&lt;/td&gt;
&lt;td&gt;Can cluster at low request counts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a server where &lt;code&gt;/heavy&lt;/code&gt; takes 5 s but &lt;code&gt;/data/:key&lt;/code&gt; takes 5 ms, &lt;strong&gt;Least Connections&lt;/strong&gt; is the right pick. Round Robin would keep handing new requests to a server already stuck on a slow one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Round Robin:  Server A busy on /heavy (5s) → still gets 2 more → queue builds
Least Conn:   Server A shows 3 active     → skip it → route to Server B (0 active) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer 4 vs Layer 7
&lt;/h3&gt;

&lt;p&gt;"Reverse proxy" and "load balancer" are not separate things — any proxy that hides backends can trivially point at several of them. The meaningful distinction is which network layer it operates on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 4 (transport):  routes packets by IP + port. Fast, protocol-blind.
                      Example: AWS Network Load Balancer.
Layer 7 (application): understands HTTP — can route by path, header, cookie.
                      Example: Nginx, HAProxy, Envoy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layer 7 costs a little more per request but can make smart, content-aware routing decisions. This prototype uses Nginx (Layer 7).&lt;/p&gt;

&lt;h3&gt;
  
  
  Latency vs Throughput
&lt;/h3&gt;

&lt;p&gt;Externalizing state has a cost, and naming it correctly matters. Two independent measures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Latency    = how long ONE request takes         (a single car driving A → B)
Throughput = how many requests complete per unit (cars arriving per hour)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding servers (or highway lanes) does &lt;strong&gt;not&lt;/strong&gt; make a single request faster. It lets the system handle more requests at once. Concretely, moving from a local file to an external store:&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;Phase 1 (local file)&lt;/th&gt;
&lt;th&gt;Phase 2 (external store)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;~0.2 ms&lt;/td&gt;
&lt;td&gt;~5 ms (25× slower)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;~5K req/s&lt;/td&gt;
&lt;td&gt;~30K req/s (6× more)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure tolerance&lt;/td&gt;
&lt;td&gt;zero&lt;/td&gt;
&lt;td&gt;survives a server dying&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade is deliberate: accept higher per-request latency in exchange for far higher throughput and the elimination of the single point of failure. As long as latency stays under the ~100 ms human-perception threshold, users don't notice — but they very much notice an outage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Concurrent Requests Don't Run Sequentially
&lt;/h2&gt;

&lt;p&gt;A common misread: "if each request now waits 5 ms on the database, 10,000 requests take 50 seconds." That's wrong, and understanding why is the crux of the phase.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;await&lt;/code&gt; yields the event loop. While one request waits on the database, the server starts the next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=0.00 ms  Req 1 arrives → await db.read() → YIELDS
t=0.01 ms  Req 2 arrives → await db.read() → YIELDS
t=0.02 ms  Req 3 arrives → await db.read() → YIELDS
t=2.00 ms  Req 1's response comes back → resume → reply
t=2.01 ms  Req 2's response comes back → resume → reply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request individually experiences 5 ms. They overlap in time. The system does not fall over from per-request latency — it falls over when the &lt;strong&gt;database saturates&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 servers × 10K req/s × 2 DB ops each = 60,000 DB ops/second
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that is the real lesson of horizontal scaling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:  [Server] ← ceiling was here (Phase 1: ~15K connections)

AFTER:   [Server 1] ─┐
         [Server 2] ─┼─→ [Database] ← ceiling moved HERE
         [Server 3] ─┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scaling doesn't eliminate bottlenecks. It moves them downstream.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Experiments Showed
&lt;/h2&gt;

&lt;p&gt;The prototype makes this concrete: the Phase 1 server rewritten as an "amnesia server" (all &lt;code&gt;fs&lt;/code&gt; calls replaced with Redis &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;set&lt;/code&gt;), packaged into one Docker image, run as three containers behind an Nginx load balancer, all sharing one Redis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → Nginx (:80) → server1:3000 ─┐
                       server2:3000 ─┼─→ Redis (:6379)  ← shared state
                       server3:3000 ─┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every response includes &lt;code&gt;served_by: os.hostname()&lt;/code&gt; to prove distribution. Three verifications passed first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load distribution&lt;/strong&gt; — 10 parallel &lt;code&gt;/stats&lt;/code&gt; requests returned 3 different hostnames (a 4-3-3 split).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared state&lt;/strong&gt; — a &lt;code&gt;PUT&lt;/code&gt; through one server was immediately readable through the other two. Redis is the single source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server death&lt;/strong&gt; — &lt;code&gt;docker stop server1&lt;/code&gt;, then read: the remaining two servers served all data with zero loss. The server is genuinely disposable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then came breaking it on purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 1 — Kill the shared state: a hang is worse than a crash
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker stop&lt;/code&gt; on Redis, then a request. The expectation was an error response. What actually happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl → server → await redis.get() → (never resolves) → request HANGS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No crash, no error — the Redis client silently retries the connection forever, so &lt;code&gt;await redis.get()&lt;/code&gt; never settles. This is the worst failure mode: a crash returns an error instantly, but a hang ties up the connection, and enough hangs exhaust Nginx's connection pool and freeze the entire system. &lt;strong&gt;Always set timeouts on external service calls.&lt;/strong&gt; A fast failure is recoverable; a silent hang cascades.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 2 — Restart Redis: did the data survive?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker start&lt;/code&gt; on Redis, then read the old keys. The data was still there. Redis writes an RDB snapshot to disk on a graceful shutdown (&lt;code&gt;SIGTERM&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The caveat matters: a &lt;code&gt;docker kill&lt;/code&gt; (&lt;code&gt;SIGKILL&lt;/code&gt;) or a real crash skips the snapshot, so everything since the last auto-save is lost. Graceful shutdown preserved durability here; an ungraceful one wouldn't have. Real durability guarantees are a Phase 3 topic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 3 — Kill the load balancer: the new single point of failure
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker stop&lt;/code&gt; on Nginx, then &lt;code&gt;curl localhost&lt;/code&gt;: connection refused. The servers and Redis were all healthy — reachable directly via &lt;code&gt;docker exec&lt;/code&gt; — but unreachable from outside, because Nginx is the only public entry point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → [Nginx]  → Server 1 → [Redis]
          SPOF       Server 2    SPOF
                     Server 3
                    (redundant)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the servers gained redundancy. Scaling moved the single point of failure (SPOF) from the server to &lt;em&gt;two&lt;/em&gt; new places: the load balancer in front and the shared state behind.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix Reveals the Lesson
&lt;/h2&gt;

&lt;p&gt;Each break has a distinct remedy, and each remedy points at a later phase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State divergence across servers&lt;/strong&gt; → externalize every kind of state to a shared service; keep the server a pure function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hang on dependency failure&lt;/strong&gt; → wrap every external call in a timeout (and eventually a circuit breaker — Phase 11).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancer / shared-state SPOF&lt;/strong&gt; → run multiple load balancers behind a cloud LB, and replicate the shared state (Phase 6).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The surface symptoms differ, but the shape is identical to Phase 1: the component that fails determines both the symptom and the fix. What changed is that "the component" is now something &lt;em&gt;downstream&lt;/em&gt; of the server you added — which is exactly what horizontal scaling does to bottlenecks.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Phase Actually Teaches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Horizontal scaling is an application property, not an infrastructure one.&lt;/strong&gt; Adding servers only helps if the servers are stateless. The hard work is externalizing state (data, counters, coordination); provisioning the machines is the easy part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Shared-nothing makes servers disposable, and disposable is the whole goal.&lt;/strong&gt; When any request can go to any server, you can deploy one at a time, tolerate hardware death, and restart freely — the three things a single machine can never do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Sticky sessions quietly reintroduce single-machine failure.&lt;/strong&gt; Pinning users to servers is a crutch; a dying server takes its pinned users down with it. Full statelessness is the target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The load-balancing algorithm must match request-cost variance.&lt;/strong&gt; With uniform requests, Round Robin is fine. With a mix of 5 ms and 5 s requests, Least Connections avoids piling work onto an already-busy server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Scaling moves bottlenecks downstream — it never deletes them.&lt;/strong&gt; Three servers turn one ceiling (connections) into a new one (database throughput at ~60K ops/s) plus two new SPOFs (the LB and the shared state). Know where the ceiling went.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. A hang is worse than a crash.&lt;/strong&gt; An unbounded wait on a dead dependency ties up resources and cascades. Every external call needs a timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision heuristic:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Need zero-downtime deploys / HA, low complexity   → monolith × N behind a load balancer
Requests vary wildly in cost                      → Least Connections
Uniform requests, equal servers                   → Round Robin
Need path/header/cookie-aware routing             → Layer 7 (Nginx/Envoy)
Need raw packet throughput, protocol-blind        → Layer 4 (cloud NLB)
Independent scaling / team boundaries force it     → microservices (Phase 9), not before
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where I'm Still Fuzzy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Timeout values on external calls.&lt;/strong&gt; I know an unbounded wait is the failure — but what's the &lt;em&gt;right&lt;/em&gt; timeout? Too low and you fail healthy-but-slow requests; too high and you hang. It presumably depends on the dependency's p99, but I don't have a principled way to set it yet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Load balancer redundancy in practice.&lt;/strong&gt; "Run multiple Nginx behind a cloud LB" is the stock answer, but that just pushes the SPOF up to the cloud LB. Where does the regress actually stop — DNS? Anycast? I haven't built the layer above Nginx.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;When Least Connections misleads.&lt;/strong&gt; It's blind to server capacity, so a weak server with few connections still looks attractive. In a heterogeneous fleet, when does that go wrong, and is weighted-least-connections the real answer?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;The prototype is at &lt;a href="https://github.com/aishwarya-chamanoor/system-design-agenticway" rel="noopener noreferrer"&gt;github.com/aishwarya-chamanoor/system-design-agenticway&lt;/a&gt; in &lt;code&gt;phase-02/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aishwarya-chamanoor/system-design-agenticway
&lt;span class="nb"&gt;cd &lt;/span&gt;system-design-agenticway/phase-02
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four experiments worth running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hit &lt;code&gt;http://localhost/stats&lt;/code&gt; ten times in parallel — watch &lt;code&gt;served_by&lt;/code&gt; cycle across three hostnames.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT&lt;/code&gt; a value through one request, then &lt;code&gt;GET&lt;/code&gt; it repeatedly — every server returns the same value because Redis owns it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker stop &amp;lt;server-container&amp;gt;&lt;/code&gt; and keep reading — the surviving servers serve everything; the server is disposable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker stop &amp;lt;redis-container&amp;gt;&lt;/code&gt; and send one request — watch it &lt;strong&gt;hang&lt;/strong&gt; instead of erroring. That hang is the lesson.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Phase 3 goes inside the shared store this phase leaned on — asking what "the data survived" really guarantees, and what it costs to make that guarantee real.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of a 12-phase series building distributed systems intuition from first principles. Each phase has a running prototype, failure scenarios, and a gate check before moving on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>learning</category>
      <category>beginners</category>
    </item>
    <item>
      <title>System Design the Agentic Way — Phase 1: The Single Machine Ceiling</title>
      <dc:creator>HappyBug</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:34:05 +0000</pubDate>
      <link>https://dev.to/happybug/system-design-the-agentic-way-phase-1-the-single-machine-ceiling-2i8n</link>
      <guid>https://dev.to/happybug/system-design-the-agentic-way-phase-1-the-single-machine-ceiling-2i8n</guid>
      <description>&lt;h1&gt;
  
  
  Phase 1: The Single Machine Ceiling
&lt;/h1&gt;

&lt;p&gt;Before reasoning about distributed systems, you need a concrete model of what a single machine actually runs out of. Phase 1 builds that model from the ground up — not from diagrams, but from pushing a server until it stops accepting connections, and understanding exactly what gave out and why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Question&lt;/li&gt;
&lt;li&gt;
The Building Blocks: What Actually Handles a Connection

&lt;ul&gt;
&lt;li&gt;File Descriptors&lt;/li&gt;
&lt;li&gt;TCP Connections: The Handshake&lt;/li&gt;
&lt;li&gt;Keep-Alive: Efficiency With a Hidden Cost&lt;/li&gt;
&lt;li&gt;TIME_WAIT: The Port Cooldown&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The Resource Landscape&lt;/li&gt;
&lt;li&gt;
Concurrency Models: How the Server Handles Multiple Users

&lt;ul&gt;
&lt;li&gt;Thread-per-Connection&lt;/li&gt;
&lt;li&gt;Event Loop&lt;/li&gt;
&lt;li&gt;Thread Pool&lt;/li&gt;
&lt;li&gt;The Comparison That Matters&lt;/li&gt;
&lt;li&gt;The "Pure Event Loop" Lie&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;What the Experiments Showed&lt;/li&gt;
&lt;li&gt;The Fix Reveals the Lesson&lt;/li&gt;
&lt;li&gt;What This Phase Actually Teaches&lt;/li&gt;
&lt;li&gt;Where I'm Still Fuzzy&lt;/li&gt;
&lt;li&gt;Try It Yourself&lt;/li&gt;
&lt;li&gt;Next&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Question
&lt;/h2&gt;

&lt;p&gt;A server has CPU, memory, disk, and network capacity. When it fails under load, which one ran out — and does the answer change what you do about it?&lt;/p&gt;

&lt;p&gt;Phase 1 is an argument that it matters enormously, and that most engineers are watching the wrong gauges.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Building Blocks: What Actually Handles a Connection
&lt;/h2&gt;

&lt;p&gt;Before getting to failure modes, it helps to have a concrete model of what happens when a user connects to a server.&lt;/p&gt;

&lt;h3&gt;
  
  
  File Descriptors
&lt;/h3&gt;

&lt;p&gt;The OS tracks every open resource — files, network connections, pipes — using a small integer called a &lt;strong&gt;file descriptor (FD)&lt;/strong&gt;. The first three are always reserved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FD 0 → stdin  (keyboard input)
FD 1 → stdout (console output)
FD 2 → stderr (error output)
FD 3 → your log file
FD 4 → user A's connection
FD 5 → user B's connection
...
FD 1024 → OS says "EMFILE — no more" ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every TCP connection your server accepts consumes one FD. The default per-process limit on Linux is &lt;strong&gt;1,024&lt;/strong&gt;. When that fills, the OS returns &lt;code&gt;EMFILE&lt;/code&gt; (too many open files) and refuses new connections — regardless of how much CPU or memory you have left.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ulimit&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;        &lt;span class="c"&gt;# check current limit → probably 1024&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ulimit&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 65535  &lt;span class="c"&gt;# raise it (current session only)&lt;/span&gt;
&lt;span class="c"&gt;# Permanent: edit /etc/security/limits.conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  TCP Connections: The Handshake
&lt;/h3&gt;

&lt;p&gt;Every HTTP connection starts with a TCP three-way handshake before any data flows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → Server: SYN      ("I'd like to connect")
Server → Client: SYN-ACK  ("Got it, I'm ready")
Client → Server: ACK       ("Confirmed — send your request")
Client → Server: [data]    (the actual HTTP request)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step takes a network round-trip. For a client in the same data center that's ~0.1ms; across continents it's 100–300ms. This overhead motivated &lt;strong&gt;HTTP Keep-Alive&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep-Alive: Efficiency With a Hidden Cost
&lt;/h3&gt;

&lt;p&gt;HTTP Keep-Alive reuses a single TCP connection for multiple requests instead of handshaking for each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without: handshake → request → close,  handshake → request → close  (2 handshakes)
With:    handshake → request → request → request → close             (1 handshake)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Faster for the client. But idle keep-alive connections still hold an FD open on the server doing nothing. 5,000 users sitting on an idle browser tab = 5,000 FDs consumed. This is a trap that ended the startup in the war story below.&lt;/p&gt;

&lt;h3&gt;
  
  
  TIME_WAIT: The Port Cooldown
&lt;/h3&gt;

&lt;p&gt;When a TCP connection closes, the port on the &lt;em&gt;client&lt;/em&gt; side enters &lt;strong&gt;TIME_WAIT&lt;/strong&gt; for 60–120 seconds. During this window, that port can't be reused for new outbound connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000 outbound connections/sec × 60 sec TIME_WAIT = 60,000 ports locked
Total available ephemeral ports: ~65,000
→ Port exhaustion, even with healthy CPU and memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters most on servers that make many outbound connections (proxies, services that call many APIs). You'll see &lt;code&gt;connect: Cannot assign requested address&lt;/code&gt; in logs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Resource Landscape
&lt;/h2&gt;

&lt;p&gt;A server has five independent ceilings, each with its own failure signature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;What fills it&lt;/th&gt;
&lt;th&gt;Failure signature&lt;/th&gt;
&lt;th&gt;Default limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File descriptors&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TCP connections, open files&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EMFILE&lt;/code&gt; — new connections refused&lt;/td&gt;
&lt;td&gt;1,024/process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Heap, thread stacks, buffers&lt;/td&gt;
&lt;td&gt;OOM Killer silently kills process&lt;/td&gt;
&lt;td&gt;Depends on RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Computation, context switching&lt;/td&gt;
&lt;td&gt;All requests slow down together&lt;/td&gt;
&lt;td&gt;100% across cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disk IOPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read/write operations&lt;/td&gt;
&lt;td&gt;Write latency spikes&lt;/td&gt;
&lt;td&gt;SSD: ~100K–500K/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network ports&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Outbound connections (TIME_WAIT)&lt;/td&gt;
&lt;td&gt;New outbound connections fail&lt;/td&gt;
&lt;td&gt;~65K ephemeral ports&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The critical distinction: &lt;strong&gt;CPU and memory saturation cause gradual degradation&lt;/strong&gt; — everything slows down proportionally. &lt;strong&gt;FD and port exhaustion cause total failure&lt;/strong&gt; — new connections are refused while existing ones and dashboard metrics look completely healthy. The second kind is the one that produces a 47-minute outage where nobody can figure out why the server is "down."&lt;/p&gt;




&lt;h2&gt;
  
  
  Concurrency Models: How the Server Handles Multiple Users
&lt;/h2&gt;

&lt;p&gt;How a server handles multiple simultaneous connections determines which ceiling it hits first. There are three main approaches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Thread-per-Connection
&lt;/h3&gt;

&lt;p&gt;A new OS thread is created for each incoming connection. That thread handles everything for that connection — reading the request, processing it, writing the response — then terminates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User A connects → Thread 1 created (1MB RAM)
User B connects → Thread 2 created (1MB RAM)
User C connects → Thread 3 created (1MB RAM)
...
User 32,000 → Thread 32,000 → OOM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple to code: each thread runs straight-line blocking code. But each thread costs ~1MB of stack space, and &lt;strong&gt;context switching&lt;/strong&gt; between thousands of threads burns CPU. At 10,000 threads, 10–30% of CPU goes just to saving and restoring thread state. At 100,000 — the system is unusable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First ceiling:&lt;/strong&gt; Memory (32GB ÷ 1MB/thread ≈ 32,000 max threads), then CPU from context switching.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Loop
&lt;/h3&gt;

&lt;p&gt;One thread handles all connections by processing callbacks as they become ready. Instead of blocking while waiting for I/O, it registers a callback and moves on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hotel receptionist analogy:
  Loop forever:
    1. Any new guests?        → start check-in
    2. Any responses ready?   → send them
    3. Any room service done? → notify the room (non-blocking)
    4. Back to step 1

ONE receptionist. MANY guests. Never stands idle waiting.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each connection costs only a few KB (just a file descriptor and some socket state), not 1MB. The same 32GB server can hold 100,000+ connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First ceiling:&lt;/strong&gt; File descriptors (OS limit), or CPU if any one request does synchronous computation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weakness:&lt;/strong&gt; Head-of-line blocking. One slow synchronous task holds the single thread, freezing every other connection behind it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Drive-through analogy:
  Car A: "Custom birthday cake" (10 min)
  Car B: "Just a coffee"  ← waits 10 minutes behind Car A
  Car C: "A muffin"       ← waits 10:30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Thread Pool
&lt;/h3&gt;

&lt;p&gt;A fixed number of pre-created threads pull work from a shared queue. Balances the two extremes: bounded memory cost (N threads, not one per connection), and true parallelism across CPU cores.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hire 20 waiters. All new tables wait in the lobby.
A free waiter → grabs next table → serves → returns to lobby.
If all 20 are busy → table waits in queue.
If queue is full → reject with 503.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pool size formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pool_size = num_cores × (1 + wait_time / compute_time)
Example: 8 cores, 90ms DB wait, 10ms compute → 8 × 10 = 80 threads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Failure mode:&lt;/strong&gt; If a slow downstream dependency holds all threads (e.g., 200 threads × 30-second timeout = all blocked), the queue fills and the service rejects everything — even though your code is fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Comparison That Matters
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Thread-per-Connection&lt;/th&gt;
&lt;th&gt;Event Loop&lt;/th&gt;
&lt;th&gt;Thread Pool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory @ 10K connections&lt;/td&gt;
&lt;td&gt;~10 GB&lt;/td&gt;
&lt;td&gt;~50 MB&lt;/td&gt;
&lt;td&gt;~500 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max practical connections&lt;/td&gt;
&lt;td&gt;1K–10K&lt;/td&gt;
&lt;td&gt;10K–100K+&lt;/td&gt;
&lt;td&gt;10K–50K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;OOM / context-switch death spiral&lt;/td&gt;
&lt;td&gt;Head-of-line blocking&lt;/td&gt;
&lt;td&gt;Queue overflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Race conditions&lt;/td&gt;
&lt;td&gt;Every CPU instruction boundary&lt;/td&gt;
&lt;td&gt;Only at &lt;code&gt;await&lt;/code&gt; boundaries&lt;/td&gt;
&lt;td&gt;Every CPU instruction boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who uses it&lt;/td&gt;
&lt;td&gt;Apache httpd&lt;/td&gt;
&lt;td&gt;Node.js, nginx, Redis&lt;/td&gt;
&lt;td&gt;Java Tomcat, Go/Netty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The "Pure Event Loop" Lie
&lt;/h3&gt;

&lt;p&gt;Node.js is described as single-threaded. It isn't, exactly. The event loop handles network I/O directly (non-blocking), but &lt;strong&gt;file I/O and DNS lookups are blocking operations&lt;/strong&gt; that can't be done asynchronously by the OS. Node offloads these to a small internal thread pool via libuv:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Event loop handles:          libuv thread pool (default: 4 threads) handles:
  TCP connections              fs.readFile / writeFile
  HTTP parsing                 DNS lookups (dns.lookup)
  DB queries over network      crypto (pbkdf2, scrypt)
  setTimeout / setInterval     zlib compression
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 4-thread default is dangerously low in production. If 4 file reads are in progress, a DNS lookup queues behind them. HTTP calls to external APIs start timing out for no obvious reason. The fix: &lt;code&gt;UV_THREADPOOL_SIZE=64&lt;/code&gt; set before the process starts.&lt;/p&gt;

&lt;p&gt;Every "event loop" runtime is actually a hybrid. Knowing which operations go to the thread pool and which stay on the event loop is essential for debugging latency spikes.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Experiments Showed
&lt;/h2&gt;

&lt;p&gt;The prototype: a Node.js HTTP key-value store, raw &lt;code&gt;http&lt;/code&gt; module, zero npm packages. Four routes: &lt;code&gt;/data/:key&lt;/code&gt; (read/write), &lt;code&gt;/stats&lt;/code&gt; (live metrics — memory, FDs, event loop lag), and &lt;code&gt;/heavy&lt;/code&gt; (a deliberate CPU blocker). Simple enough to break in predictable ways.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 1 — Connection flood: the ceiling was the OS kernel
&lt;/h3&gt;

&lt;p&gt;Opening TCP connections without closing them, stepping up in batches. The server stayed alive until:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;15,965 connections → ENOBUFS (no buffer space available)
CPU: ~0%    Node heap: ~480 MB (healthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The event loop was not blocked. Node's memory was fine. What ran out was &lt;strong&gt;OS kernel network buffer space&lt;/strong&gt; — roughly 250 MB of non-paged pool memory consumed by socket receive/send buffers (~16 KB each). The ceiling was in the kernel, not in the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ceiling hierarchy hit in order:
  1. OS kernel network buffers  ← actual ceiling on Windows
  2. FD limit (~16K on Windows) ← would have been next
  3. Node.js heap               ← never reached
  4. CPU                        ← never reached
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the war story that opens the phase: a social media API during a viral spike. CPU at 5%, memory at 40%, every dashboard looked healthy — but the server was refusing every new connection. 47-minute outage. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ulimit&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 65535   &lt;span class="c"&gt;# raises the per-process FD limit from the default 1,024&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point isn't to memorize &lt;code&gt;ulimit&lt;/code&gt;. It's that the server had massive headroom on four of its five ceilings, and failed on the fifth one nobody was measuring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 2 — One CPU-bound request freezes all 15,965 users
&lt;/h3&gt;

&lt;p&gt;A single request to &lt;code&gt;/heavy&lt;/code&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="c1"&gt;// This holds the event loop thread — nothing else can run&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&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;while &lt;/span&gt;&lt;span class="p"&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During those 5 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/stats response time:  5,000 ms  (normally 1 ms)
Event loop lag:        12,000 ms
CPU:                   98%
All other requests:    frozen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/stats&lt;/code&gt; route does trivial work — read process memory, count handles, respond. Under normal conditions it takes 1ms. The while loop on &lt;code&gt;/heavy&lt;/code&gt; blocked it for 5 full seconds because &lt;strong&gt;the event loop is a single thread with cooperative scheduling&lt;/strong&gt;. There is no OS scheduler interrupting a running synchronous task to let another request proceed. The CPU-bound route held the thread until it finished.&lt;/p&gt;

&lt;p&gt;This surfaces in production from: &lt;code&gt;JSON.parse()&lt;/code&gt; on a 50 MB payload, an unanchored regex on user input (ReDoS), or a forgotten &lt;code&gt;fs.readFileSync()&lt;/code&gt; in a request handler. All of them stall every connected user simultaneously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 3 — Async doesn't make reads safe
&lt;/h3&gt;

&lt;p&gt;100 concurrent PUT requests, each reading the current counter, incrementing it, and writing it back. Expected final value: 100. Actual: 60-something.&lt;/p&gt;

&lt;p&gt;The interleaving that causes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request A: await readStore()   → gets { counter: 1 }  ← yields here
Request B: await readStore()   → gets { counter: 1 }  ← stale; A hasn't written
Request A: await writeStore({ counter: 2 })
Request B: await writeStore({ counter: 2 })            ← overwrites; should be 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assumption going in: single-threaded event loop means no interleaving. That's true &lt;em&gt;between&lt;/em&gt; &lt;code&gt;await&lt;/code&gt; calls — code between two awaits is atomic. But the full async function is not. Every &lt;code&gt;await&lt;/code&gt; is a yield point where the event loop can run another callback. In a read-modify-write pattern, that's enough.&lt;/p&gt;

&lt;p&gt;The same lost-update problem exists in multi-threaded code, just at every CPU instruction instead of only at &lt;code&gt;await&lt;/code&gt; boundaries. The event loop makes races less frequent, not impossible.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix Reveals the Lesson
&lt;/h2&gt;

&lt;p&gt;Each experiment has a distinct fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FD/buffer exhaustion&lt;/strong&gt; → raise &lt;code&gt;ulimit -n&lt;/code&gt; / set &lt;code&gt;nofile&lt;/code&gt; in systemd unit or container config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event loop blocking&lt;/strong&gt; → offload synchronous CPU work to &lt;code&gt;worker_threads&lt;/code&gt;; never call blocking APIs in a hot path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async race conditions&lt;/strong&gt; → serialize writes through a queue, or use atomic DB operations (transactions, compare-and-swap)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three experiments produced similar surface symptoms — slow or unresponsive server. The root causes are completely different. Applying the wrong intervention (or adding a second server) wouldn't have helped. The resource that fails determines both the symptom and the remedy.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Phase Actually Teaches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Not all resource ceilings behave like slowness.&lt;/strong&gt; FD and port exhaustion produce hard refusals while CPU and memory metrics look healthy. This class of failure is disproportionately misdiagnosed because teams are watching the wrong gauges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The concurrency model determines which ceiling you hit first.&lt;/strong&gt; Thread-per-connection hits memory first (~1MB/thread → ~32K threads on 32GB RAM). Event-loop hits file descriptors or CPU-bound blocking first. The model isn't just a performance choice — it changes the failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Phase Actually Teaches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A server has five independent resource ceilings, and the smallest one is the actual limit.&lt;/strong&gt; CPU saturation causes gradual degradation. FD exhaustion and port exhaustion cause total failure while all other metrics look fine. Know which ceiling you're approaching before an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The thread-per-connection model kills itself on memory and context switching.&lt;/strong&gt; At 10,000 threads: ~10 GB of stack space, and 10–30% of CPU wasted on saving/restoring thread state. This is why Node.js, nginx, and Redis all chose the event loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The event loop trades memory efficiency for CPU vulnerability.&lt;/strong&gt; A single synchronous task holds the thread. Head-of-line blocking isn't a bug to fix — it's a fundamental property of cooperative concurrency. The architecture choice is: which failure mode can I tolerate? OOM death spiral, or one hot request freezing everyone?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "Single-threaded" is a simplification.&lt;/strong&gt; Node.js has 4 libuv worker threads by default handling file I/O, DNS, and crypto. DNS lookups queue behind file reads. In production, set &lt;code&gt;UV_THREADPOOL_SIZE=64&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Async code has race conditions at await boundaries.&lt;/strong&gt; Code between two awaits is atomic. A full async function is not. Every read-modify-write pattern across awaits needs explicit serialization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use which model:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt; 5K connections, simple code needed        → Thread-per-connection
&amp;gt; 10K connections, mostly I/O-bound         → Event loop
Mixed CPU + I/O, need parallelism           → Thread pool
Production at scale                         → Hybrid (event loop + worker pool)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where I'm Still Fuzzy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;ENOBUFS&lt;/code&gt; vs &lt;code&gt;EMFILE&lt;/code&gt;&lt;/strong&gt; — I hit &lt;code&gt;ENOBUFS&lt;/code&gt; at ~16K connections on Windows, which maps to OS kernel buffer exhaustion, not the FD limit. On Linux with default &lt;code&gt;ulimit -n 1024&lt;/code&gt;, &lt;code&gt;EMFILE&lt;/code&gt; would appear much earlier. Are these always separable in production?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TIME_WAIT at high connection churn&lt;/strong&gt; — I understand port exhaustion in theory (1K conns/sec × 60s = 60K locked ports), but I haven't built something that actually hits it. That would require a high-outbound-connection scenario — probably relevant in Phase 9 (service decomposition) when services call each other.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;libuv thread pool saturation in practice&lt;/strong&gt; — the 4-thread default is clearly too low, but what does the performance curve look like as you raise it? Is there a point where more threads hurt?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Four experiments worth running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;GET /stats&lt;/code&gt; — see live FD count, heap usage, and event loop lag&lt;/li&gt;
&lt;li&gt;Hit &lt;code&gt;/heavy&lt;/code&gt; in one tab, then hit &lt;code&gt;/stats&lt;/code&gt; from another — watch the lag number&lt;/li&gt;
&lt;li&gt;Run 100 concurrent writes to a counter and check if the final value matches the expected count&lt;/li&gt;
&lt;li&gt;Open thousands of connections without closing them and watch when the OS complains first&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The stats endpoint measures event loop lag by scheduling a &lt;code&gt;setTimeout(0)&lt;/code&gt; callback and measuring how long before it actually runs. Under normal load: &amp;lt; 5ms. During a CPU-bound request: in the thousands.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Phase 2 moves the problem to multiple machines — which immediately surfaces a new class of failures that single-machine thinking doesn't predict.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of a 12-phase series building distributed systems intuition from first principles. Each phase has a running prototype, failure scenarios, and a gate check before moving on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>beginners</category>
      <category>learning</category>
    </item>
    <item>
      <title>System Design the Agentic Way — How I'm Using AI Agents to Actually Learn Distributed Systems</title>
      <dc:creator>HappyBug</dc:creator>
      <pubDate>Mon, 13 Jul 2026 06:30:28 +0000</pubDate>
      <link>https://dev.to/happybug/system-design-the-agentic-way-how-im-using-ai-agents-to-actually-learn-distributed-systems-529f</link>
      <guid>https://dev.to/happybug/system-design-the-agentic-way-how-im-using-ai-agents-to-actually-learn-distributed-systems-529f</guid>
      <description>&lt;h2&gt;
  
  
  System Design the Agentic Way
&lt;/h2&gt;

&lt;p&gt;I wanted to learn system design. Not "memorize CAP theorem for interviews" learn — actually understand why things break at scale and what to do about it.&lt;/p&gt;

&lt;p&gt;The problem: most resources give you theory without pain. You read about load balancers without ever watching one fail. You learn about replication without seeing stale data appear in front of you.&lt;/p&gt;

&lt;p&gt;So I built something different.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Idea
&lt;/h3&gt;

&lt;p&gt;What if I had a pair-programmer that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starts every topic with a &lt;strong&gt;real production failure&lt;/strong&gt; (so I feel why it matters)&lt;/li&gt;
&lt;li&gt;Makes me &lt;strong&gt;predict&lt;/strong&gt; what will happen before I run anything&lt;/li&gt;
&lt;li&gt;Forces me to &lt;strong&gt;build&lt;/strong&gt; minimal prototypes, not just read&lt;/li&gt;
&lt;li&gt;Then makes me &lt;strong&gt;break them&lt;/strong&gt; deliberately&lt;/li&gt;
&lt;li&gt;And finally &lt;strong&gt;tests my judgment&lt;/strong&gt; with ambiguous 3am scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's what I built — using GitHub Copilot's custom agents and skills as a structured teaching system.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Curriculum
&lt;/h3&gt;

&lt;p&gt;12 phases. Each one builds on the previous. No skipping.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;What Goes Wrong Without It&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Single Machine Ceiling&lt;/td&gt;
&lt;td&gt;Your server hits 15K connections and starts refusing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Stateless Horizontal Scaling&lt;/td&gt;
&lt;td&gt;You deploy and lose 47 payments during the restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Data Storage Trade-offs&lt;/td&gt;
&lt;td&gt;Redis crashes and your last 30 seconds of writes vanish&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Caching &amp;amp; Invalidation&lt;/td&gt;
&lt;td&gt;Users see stale prices and buy at the wrong amount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Async Processing &amp;amp; Queues&lt;/td&gt;
&lt;td&gt;Payment service is down for 2 seconds, 500 orders lost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6. Replication &amp;amp; Consistency&lt;/td&gt;
&lt;td&gt;Read replicas serve yesterday's data as if it's current&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7. Partitioning &amp;amp; Sharding&lt;/td&gt;
&lt;td&gt;One customer has 90% of the data, hot partition melts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8. Coordination &amp;amp; Consensus&lt;/td&gt;
&lt;td&gt;Two nodes both think they're the leader, split-brain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9. Service Decomposition&lt;/td&gt;
&lt;td&gt;Distributed transaction fails halfway, money vanishes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10. Observability&lt;/td&gt;
&lt;td&gt;Something is wrong. You have no idea what.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11. Resilience Patterns&lt;/td&gt;
&lt;td&gt;One slow dependency freezes your entire service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12. Capacity Planning&lt;/td&gt;
&lt;td&gt;Black Friday hits 10x predictions, everything collapses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  The Method
&lt;/h3&gt;

&lt;p&gt;Every phase follows the same loop:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Motivate → Model → Decide → Build → Break → Gate&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🔥 War Story    → Feel the pain
🧠 Concepts     → Understand the mechanism
⚖️ Trade-off   → Pick an approach (and own the downsides)
🔨 Build       → Minimal prototype with real code
💥 Break It    → Deliberately kill components
🎯 Gate Check  → Scenario-based judgment test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "Break It" step is where most of the learning happens. My prediction is almost always wrong the first time. That gap — between what I expected and what actually happened — is the lesson.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Tooling
&lt;/h3&gt;

&lt;p&gt;The whole system runs inside VS Code using GitHub Copilot's custom agent features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 agents&lt;/strong&gt; — instructor, gate-keeper, adversary, reviewer, orchestrator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 skills&lt;/strong&gt; — war stories, concept deep-dives, prototype specs, failure labs, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knowledge files&lt;/strong&gt; — real production patterns, failure catalogs, anti-patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning journal&lt;/strong&gt; — auto-captured insights, predictions, and mistakes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agents don't give me answers. They ask me to predict, let me fail, then explain why.&lt;/p&gt;




&lt;h3&gt;
  
  
  Where I Am
&lt;/h3&gt;

&lt;p&gt;I've completed Phase 1 (single machine limits) and Phase 2 (horizontal scaling with Docker + Nginx + Redis). In the next posts, I'll walk through each phase — what I built, what I broke, and what surprised me.&lt;/p&gt;




&lt;h3&gt;
  
  
  Try It Yourself
&lt;/h3&gt;

&lt;p&gt;The full system is open source:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://github.com/aishwarya-chamanoor/system-design-agenticway" rel="noopener noreferrer"&gt;system-design-agenticway&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  What I'd Love From You
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;⭐ Star the repo if this approach resonates&lt;/li&gt;
&lt;li&gt;🐛 Open issues if something's broken or confusing&lt;/li&gt;
&lt;li&gt;💡 Suggest improvements to the curriculum&lt;/li&gt;
&lt;li&gt;📝 Tell me: how do YOU learn system design? What works? What doesn't?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: &lt;strong&gt;Phase 1 — The Single Machine Ceiling.&lt;/strong&gt; I'll show you what happens when 15,965 TCP connections hit one Node.js process, and what it taught me about operating systems.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for the full series. Each post covers one phase of the journey.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Clone it, open in VS Code with Copilot, and start Phase 1. All you need is Node.js and Docker.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Love From You
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;⭐ Star the repo if this approach resonates&lt;/li&gt;
&lt;li&gt;🐛 Open issues if something's broken or confusing&lt;/li&gt;
&lt;li&gt;💡 Suggest improvements to the curriculum&lt;/li&gt;
&lt;li&gt;📝 Tell me: how do YOU learn system design? What works? What doesn't?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: &lt;strong&gt;Phase 1 — The Single Machine Ceiling.&lt;/strong&gt; I'll show you what happens when 15,965 TCP connections hit one Node.js process, and what it taught me about operating systems.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow for the full series. Each post covers one phase of the journey.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>ai</category>
      <category>beginners</category>
      <category>learning</category>
    </item>
    <item>
      <title>How are people collaborating here for dev challenges? Need advice :)</title>
      <dc:creator>HappyBug</dc:creator>
      <pubDate>Thu, 04 Jun 2026 09:18:12 +0000</pubDate>
      <link>https://dev.to/happybug/how-are-people-collaborating-here-for-dev-challenges-need-advice--1abp</link>
      <guid>https://dev.to/happybug/how-are-people-collaborating-here-for-dev-challenges-need-advice--1abp</guid>
      <description></description>
      <category>community</category>
      <category>devchallenge</category>
      <category>discuss</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
