<?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: Daksh Gargas</title>
    <description>The latest articles on DEV Community by Daksh Gargas (@daksh-gargas).</description>
    <link>https://dev.to/daksh-gargas</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%2F81541%2F8e86e0e4-b356-4178-9fd3-9bde113d855e.jpg</url>
      <title>DEV Community: Daksh Gargas</title>
      <link>https://dev.to/daksh-gargas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daksh-gargas"/>
    <language>en</language>
    <item>
      <title>How Meta Turns Old RAM Into Cheap RAM (and the Ideas That Make It Work)</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Fri, 10 Jul 2026 16:04:20 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/how-meta-turns-old-ram-into-cheap-ram-and-the-ideas-that-make-it-work-4b2i</link>
      <guid>https://dev.to/daksh-gargas/how-meta-turns-old-ram-into-cheap-ram-and-the-ideas-that-make-it-work-4b2i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9d1owqmny0qn7eltacs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9d1owqmny0qn7eltacs.png" alt="Cover" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's a DRAM shortage, memory is expensive, and hyperscalers own mountains of it. So when Meta retires a server, throwing away its perfectly good memory sticks is real money on fire. The problem: old sticks don't slot cleanly into new machines. Meta's fix is a small bridge chip plus a clever reuse of ideas the operating system has had for years. Three ideas do the heavy lifting — NUMA, hot/cold tiering, and CXL — and they stack together nicely.&lt;/p&gt;

&lt;h2&gt;
  
  
  NUMA: not all RAM is equally close
&lt;/h2&gt;

&lt;p&gt;A modern server usually has more than one CPU chip on the motherboard. Each chip has its own bank of RAM wired directly to it — its &lt;em&gt;local&lt;/em&gt; memory. The chips are also linked to each other, so CPU 0 can reach CPU 1's RAM if it needs to. It just has to hop across the link between them first, which costs time.&lt;/p&gt;

&lt;p&gt;So access speed depends on &lt;em&gt;where&lt;/em&gt; the memory physically sits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU 0 reaching its own local RAM: fast (~80ns)&lt;/li&gt;
&lt;li&gt;CPU 0 reaching the other chip's RAM: slower (~140ns), because the request crosses the inter-chip link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Those numbers are ballpark and vary by hardware.) That's the "non-uniform" in &lt;strong&gt;NUMA — Non-Uniform Memory Access.&lt;/strong&gt; Same request, different latency depending on distance.&lt;/p&gt;

&lt;p&gt;Linux has long known about this and tries to help. A mechanism called AutoNUMA watches which CPU actually uses a given chunk of memory, and if a process's data ended up on the far chip, it quietly migrates that data over to the local one to cut the latency. The key pattern to hold onto: &lt;strong&gt;watch what gets accessed, then move it somewhere better.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hot and cold: sort data by how often you touch it
&lt;/h2&gt;

&lt;p&gt;Most workloads don't touch all their memory evenly. A small slice gets hammered constantly; the rest mostly sits idle. Call the busy stuff &lt;em&gt;hot&lt;/em&gt; and the idle stuff &lt;em&gt;cold&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Once you look at memory this way, an obvious strategy appears: keep hot data in your fast-but-small memory, and shove cold data into a slow-but-large tier. Because the slow tier only ever holds things nobody's really asking for, you rarely pay its latency penalty. This is the same idea behind CPU caches, the OS page cache, and RAM-versus-SSD — a fast small tier backed by a slow big one, with a placement policy keeping the right things in front.&lt;/p&gt;

&lt;h2&gt;
  
  
  CXL: a slower doorway for a lot more memory
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CXL — Compute Express Link&lt;/strong&gt; — lets memory talk to the CPU over PCIe, the same general-purpose high-speed lanes your graphics card uses, instead of the dedicated memory channels wired right next to the CPU. That extra hop makes CXL memory slower than native RAM, but far faster than an SSD or the network. And critically, it means memory no longer has to be a tightly-matched neighbor of the CPU — which is exactly why an old stick can join a new server through a bridge chip.&lt;/p&gt;

&lt;p&gt;On its own, slower memory sounds like a downgrade. Here's where the three ideas click together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: reuse the NUMA plumbing for tiering
&lt;/h2&gt;

&lt;p&gt;CXL memory shows up to Linux as a NUMA node — just a "far" region, like the other chip's RAM. So Meta didn't need a new operating system. They took the AutoNUMA machinery that already does &lt;em&gt;watch access, then migrate&lt;/em&gt; and pointed it at a new decision: not "which chip is closer," but "is this page hot or cold?" Hot pages stay in fast local RAM; cold pages get demoted to the slow CXL pool. Meta's version of this is called &lt;strong&gt;TPP — Transparent Page Placement&lt;/strong&gt; — and it was merged into mainline Linux, so it's not a private fork.&lt;/p&gt;

&lt;p&gt;The result: old DIMMs that would have been scrapped become a big, cheap, slower memory tier — used a bit like fast swap space — and the OS keeps its latency off the critical path by making sure only cold data lives there. Meta reports up to a 25% reduction in server count for some workloads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;A caveat worth keeping in mind: detecting "hot" isn't free. It leans on things like minor page faults and access-bit scanning, which themselves burn CPU cycles — so part of the real engineering is making the detection cheap enough that tiering doesn't eat its own savings. And the 25% figure comes from Meta's own paper, not independent benchmarks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cxl</category>
      <category>numa</category>
      <category>memory</category>
      <category>linux</category>
    </item>
    <item>
      <title>Snapshot your terminal state, restore it after a crash — Claude Code sessions included</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Tue, 07 Jul 2026 16:17:05 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/snapshot-your-terminal-state-restore-it-after-a-crash-claude-code-sessions-included-32pj</link>
      <guid>https://dev.to/daksh-gargas/snapshot-your-terminal-state-restore-it-after-a-crash-claude-code-sessions-included-32pj</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Last time I built &lt;a href="https://github.com/DenverLifeSciences/claude-sessions" rel="noopener noreferrer"&gt;&lt;code&gt;claude-sessions&lt;/code&gt;&lt;/a&gt;: one picker to resume any Claude Code session across projects. But a machine crash takes more than Claude with it — it takes your whole terminal &lt;em&gt;layout&lt;/em&gt;. Which tabs were open, which directory each one was in, which ones had a Claude session running.&lt;/p&gt;

&lt;p&gt;macOS window restoration exists, but it fails this job twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It restores &lt;strong&gt;windows, not processes&lt;/strong&gt; — your &lt;code&gt;claude&lt;/code&gt; sessions don't come back.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;can't tell a crash from a quit&lt;/strong&gt; — close your terminal on purpose for a fresh start, and it cheerfully brings back all the old tabs anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The solution
&lt;/h2&gt;

&lt;p&gt;A snapshot daemon plus an &lt;em&gt;explicit&lt;/em&gt; restore — no auto-restore, ever.&lt;/p&gt;

&lt;p&gt;Every 5 minutes, a launchd job walks every iTerm window and tab via AppleScript, and for each tab resolves the tty → the processes on it (&lt;code&gt;ps&lt;/code&gt;) → the working directory (&lt;code&gt;lsof -d cwd&lt;/code&gt;) → and whether a &lt;code&gt;claude&lt;/code&gt; process is running there, including &lt;strong&gt;which session&lt;/strong&gt;: from its &lt;code&gt;--resume&lt;/code&gt; argument, from the transcript file it holds open, or from the newest transcript for that directory. The result is a small JSON file.&lt;/p&gt;

&lt;p&gt;Snapshots accumulate as &lt;strong&gt;history&lt;/strong&gt; — the last 100 &lt;em&gt;distinct&lt;/em&gt; states, consecutive duplicates skipped, and an empty terminal never overwrites anything.&lt;/p&gt;

&lt;p&gt;After a crash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude-sessions &lt;span class="nt"&gt;--restore-crash&lt;/span&gt;   &lt;span class="c"&gt;# newest snapshot&lt;/span&gt;
claude-sessions &lt;span class="nt"&gt;--restore-pick&lt;/span&gt;    &lt;span class="c"&gt;# or: fzf through history, pick the right one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every tab comes back — as tabs of the window you're standing in — &lt;code&gt;cd&lt;/code&gt;'d to its old directory, with &lt;code&gt;claude --resume &amp;lt;session-id&amp;gt;&lt;/code&gt; rerun wherever Claude was live. Intentional quit? Just don't run it. You are the crash detector; that's the feature.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three gotchas that cost me an evening
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;launchd can't read iCloud paths.&lt;/strong&gt; If your script lives in iCloud Drive (mine syncs via &lt;code&gt;~/.claude/bin&lt;/code&gt; → iCloud), the timer dies with &lt;code&gt;Operation not permitted&lt;/code&gt; — macOS TCC silently denies background jobs access to &lt;code&gt;~/Library/Mobile Documents&lt;/code&gt;. Point the plist at a local copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single snapshot file is a footgun.&lt;/strong&gt; After a crash you reopen a near-empty terminal, the timer fires, and your pre-crash snapshot is gone — replaced by three sad tabs. That's why history, not one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't restore the original window grouping.&lt;/strong&gt; My first version faithfully recreated every window — including a junk window the snapshot had captured. Flattening everything into tabs of the current window is what you actually want.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Script, launchd template, and docs:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/DenverLifeSciences" rel="noopener noreferrer"&gt;
        DenverLifeSciences
      &lt;/a&gt; / &lt;a href="https://github.com/DenverLifeSciences/claude-sessions" rel="noopener noreferrer"&gt;
        claude-sessions
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Cross-project session picker for Claude Code CLI — fzf over all your sessions, Enter resumes each in a new tab
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;claude-sessions&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A cross-project session picker for &lt;a href="https://claude.com/claude-code" rel="nofollow noopener noreferrer"&gt;Claude Code&lt;/a&gt; CLI.&lt;/p&gt;

&lt;p&gt;Your machine crashes with seven Claude Code sessions open across five repos. &lt;code&gt;claude --resume&lt;/code&gt; only lists sessions for the directory you run it from — so recovery means remembering every repo you were in, &lt;code&gt;cd&lt;/code&gt;-ing into each one, and picking from a list, seven times.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;claude-sessions&lt;/code&gt; instead lists &lt;strong&gt;every session from every project&lt;/strong&gt; in one &lt;a href="https://github.com/junegunn/fzf" rel="noopener noreferrer"&gt;fzf&lt;/a&gt; picker, newest first. Hit Enter and the session opens in a new iTerm tab (or tmux window) running &lt;code&gt;claude --resume &amp;lt;id&amp;gt;&lt;/code&gt; in the right directory — while the picker stays open for the next one.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;enter   open session in a new tab (picker stays open)
ctrl-a  hide/show agent-teammate sessions (⛭)
ctrl-r  refresh the list
esc     quit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each line shows the session's age, project directory, git branch (when not main/master), and its opening message. A preview pane shows the last few user/assistant exchanges of the…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/DenverLifeSciences/claude-sessions" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>claudecode</category>
      <category>cli</category>
      <category>productivity</category>
      <category>macos</category>
    </item>
    <item>
      <title>Every Claude Code session across all your projects, in one list — hit Enter to resume</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Mon, 06 Jul 2026 22:40:49 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/every-claude-code-session-across-all-your-projects-in-one-list-hit-enter-to-resume-3864</link>
      <guid>https://dev.to/daksh-gargas/every-claude-code-session-across-all-your-projects-in-one-list-hit-enter-to-resume-3864</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;My Mac hard-crashed with seven Claude Code CLI sessions open, across five repos. All gone.&lt;/p&gt;

&lt;p&gt;The built-in recovery is &lt;code&gt;claude --resume&lt;/code&gt;, and it doesn't scale to seven:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only lists sessions for the &lt;strong&gt;directory you run it from&lt;/strong&gt; — you have to remember which repos you were even in, then &lt;code&gt;cd&lt;/code&gt; into each one.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;takes over the shell&lt;/strong&gt; you run it from — so you're spawning windows and retyping paths anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The solution
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcub8flqa6kmkoos4l0sb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcub8flqa6kmkoos4l0sb.png" alt="Solution" width="799" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code already stores everything you need as plain JSONL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/projects/&amp;lt;encoded-project-path&amp;gt;/&amp;lt;session-id&amp;gt;.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The first lines of each file carry the session id, working directory, git branch, and your opening message. That's a complete cross-project session index sitting on disk — it just needs &lt;a href="https://github.com/junegunn/fzf" rel="noopener noreferrer"&gt;fzf&lt;/a&gt; pointed at it.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;&lt;code&gt;claude-sessions&lt;/code&gt;&lt;/strong&gt;, one script.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every session from &lt;strong&gt;every project&lt;/strong&gt; in one list, newest first, with a live preview of the conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enter&lt;/strong&gt; opens the highlighted session in a new iTerm tab (or tmux window): &lt;code&gt;cd &amp;lt;project&amp;gt; &amp;amp;&amp;amp; claude --resume &amp;lt;id&amp;gt;&lt;/code&gt; — in the right directory, with Claude's own "summary or full session?" prompt appearing in the new tab. The picker stays open for the next one.&lt;/li&gt;
&lt;li&gt;Sessions spawned by &lt;strong&gt;agent teams&lt;/strong&gt; are tagged &lt;code&gt;⛭ agent-name&lt;/code&gt;; &lt;strong&gt;ctrl-a&lt;/strong&gt; hides them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Restoring my seven sessions: Enter, seven times. ~10 seconds.&lt;/p&gt;
&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;Needs &lt;code&gt;fzf&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt;. Tab-opening is macOS (iTerm2/Terminal); inside tmux it works on Linux too.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/DenverLifeSciences/claude-sessions/main/claude-sessions &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/local/bin/claude-sessions
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/claude-sessions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Script, docs, and caveats (the storage format is undocumented — this only ever &lt;em&gt;reads&lt;/em&gt; it):&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/DenverLifeSciences" rel="noopener noreferrer"&gt;
        DenverLifeSciences
      &lt;/a&gt; / &lt;a href="https://github.com/DenverLifeSciences/claude-sessions" rel="noopener noreferrer"&gt;
        claude-sessions
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Cross-project session picker for Claude Code CLI — fzf over all your sessions, Enter resumes each in a new tab
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;claude-sessions&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A cross-project session picker for &lt;a href="https://claude.com/claude-code" rel="nofollow noopener noreferrer"&gt;Claude Code&lt;/a&gt; CLI.&lt;/p&gt;

&lt;p&gt;Your machine crashes with seven Claude Code sessions open across five repos. &lt;code&gt;claude --resume&lt;/code&gt; only lists sessions for the directory you run it from — so recovery means remembering every repo you were in, &lt;code&gt;cd&lt;/code&gt;-ing into each one, and picking from a list, seven times.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;claude-sessions&lt;/code&gt; instead lists &lt;strong&gt;every session from every project&lt;/strong&gt; in one &lt;a href="https://github.com/junegunn/fzf" rel="noopener noreferrer"&gt;fzf&lt;/a&gt; picker, newest first. Hit Enter and the session opens in a new iTerm tab (or tmux window) running &lt;code&gt;claude --resume &amp;lt;id&amp;gt;&lt;/code&gt; in the right directory — while the picker stays open for the next one.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;enter   open session in a new tab (picker stays open)
ctrl-a  hide/show agent-teammate sessions (⛭)
ctrl-r  refresh the list
esc     quit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Each line shows the session's age, project directory, git branch (when not main/master), and its opening message. A preview pane shows the last few user/assistant exchanges of the…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/DenverLifeSciences/claude-sessions" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>claudecode</category>
      <category>cli</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Database Rate Limiting: The Missing Piece After a Circuit Breaker</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Sat, 27 Jun 2026 20:38:27 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/database-rate-limiting-the-missing-piece-after-a-circuit-breaker-2bp7</link>
      <guid>https://dev.to/daksh-gargas/database-rate-limiting-the-missing-piece-after-a-circuit-breaker-2bp7</guid>
      <description>&lt;p&gt;This is an extension to my previous post on &lt;a href="https://dev.to/daksh-gargas/a-circuit-breaker-alone-wont-save-your-database-3d0i"&gt;Circuit Breakers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It only decides:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Should I even try calling Redis?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the circuit is open, your application executes the fallback.&lt;/p&gt;

&lt;p&gt;For many systems, that fallback is the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   │
Circuit Breaker (Open)
   │
Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's where the real problem begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Database Becomes the New Bottleneck
&lt;/h2&gt;

&lt;p&gt;Imagine your system normally handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;100K requests/sec&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Redis serves &lt;strong&gt;99K&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Database serves &lt;strong&gt;1K&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Redis suddenly goes down.&lt;/p&gt;

&lt;p&gt;Without any protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100K Requests

↓

Database 💥
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your database was never designed to handle the entire production workload.&lt;/p&gt;

&lt;p&gt;A circuit breaker saved you from Redis timeouts—but it didn't save your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: A DB Rate Limiter
&lt;/h2&gt;

&lt;p&gt;Instead of letting every request reach the database, the application enforces a limit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;circuitBreaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsOpen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dbRateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Allow&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="m"&gt;503&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;If your database can safely handle &lt;strong&gt;5,000 QPS&lt;/strong&gt;, only those 5,000 requests are allowed through.&lt;/p&gt;

&lt;p&gt;The rest fail fast (or can be served from a local cache or stale data if available).&lt;/p&gt;

&lt;p&gt;The goal isn't to serve every request.&lt;/p&gt;

&lt;p&gt;The goal is to keep the database alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Does the Rate Limiter Live?
&lt;/h2&gt;

&lt;p&gt;One question I had while learning this was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Can't the API Gateway do this?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not really.&lt;/p&gt;

&lt;p&gt;The gateway only sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /users/123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has no idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is Redis down?&lt;/li&gt;
&lt;li&gt;Is this endpoint going to Postgres?&lt;/li&gt;
&lt;li&gt;Is it calling Elasticsearch?&lt;/li&gt;
&lt;li&gt;Is it reading Kafka?&lt;/li&gt;
&lt;li&gt;Does this endpoint even touch the database?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the service knows when it's about to hit the database.&lt;/p&gt;

&lt;p&gt;That's why the rate limiter is typically implemented &lt;strong&gt;inside the application&lt;/strong&gt;, immediately before the database call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Handler
      │
Business Logic
      │
Circuit Breaker
      │
DB Rate Limiter   ← Here
      │
Repository
      │
Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A Simple Implementation
&lt;/h2&gt;

&lt;p&gt;At its core, it's just a token bucket.&lt;/p&gt;

&lt;p&gt;Suppose the database can safely handle &lt;strong&gt;500 QPS&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;GetUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;redisCircuitBreaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsOpen&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="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;localCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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="n"&gt;localCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dbRateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Allow&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="n"&gt;Error503&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="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;If there are no tokens left, the request is rejected immediately.&lt;/p&gt;

&lt;p&gt;No waiting.&lt;/p&gt;

&lt;p&gt;No overwhelming the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  But What About Multiple Application Servers?
&lt;/h2&gt;

&lt;p&gt;This was another question that came to mind.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10 App Servers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each server allows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500 QPS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the database receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10 × 500 = 5,000 QPS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect.&lt;/p&gt;

&lt;p&gt;In practice, each instance is usually allocated a portion of the database's total capacity.&lt;/p&gt;

&lt;p&gt;Larger systems may use distributed rate limiters or adaptive concurrency limits, but the idea remains the same:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Never allow your fallback path to exceed what your database can safely handle.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Circuit Breaker&lt;/strong&gt; prevents wasted calls to a failing dependency.&lt;/li&gt;
&lt;li&gt;It does &lt;strong&gt;not&lt;/strong&gt; protect your database.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;DB Rate Limiter&lt;/strong&gt; sits inside the application, just before the database call.&lt;/li&gt;
&lt;li&gt;It intentionally rejects excess requests to keep the database healthy.&lt;/li&gt;
&lt;li&gt;During outages, preserving the system is often more important than serving every request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A resilient system isn't one that never fails—it's one that fails without taking everything else down.&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>backend</category>
      <category>redis</category>
      <category>performance</category>
    </item>
    <item>
      <title>A Circuit Breaker Alone Won't Save Your Database</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Sat, 27 Jun 2026 20:33:58 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/a-circuit-breaker-alone-wont-save-your-database-3d0i</link>
      <guid>https://dev.to/daksh-gargas/a-circuit-breaker-alone-wont-save-your-database-3d0i</guid>
      <description>&lt;p&gt;A common misconception is that a &lt;strong&gt;circuit breaker protects your database&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It doesn't.&lt;/p&gt;

&lt;p&gt;It only protects your application from repeatedly calling a dependency that is already known to be unhealthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when Redis goes down?
&lt;/h2&gt;

&lt;p&gt;Without a circuit breaker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   │
Redis (500ms timeout)
   │
DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Waits for Redis to time out.&lt;/li&gt;
&lt;li&gt;Wastes network calls.&lt;/li&gt;
&lt;li&gt;Eventually hits the database anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Enter the Circuit Breaker
&lt;/h2&gt;

&lt;p&gt;After Redis fails repeatedly, the circuit opens.&lt;/p&gt;

&lt;p&gt;Now requests become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   │
Circuit Breaker
   │
Fallback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice something?&lt;/p&gt;

&lt;p&gt;The circuit breaker &lt;strong&gt;doesn't decide the fallback&lt;/strong&gt;. It only decides:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Should I even try Redis?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So what is the fallback?
&lt;/h2&gt;

&lt;p&gt;A production system usually follows this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request
   │
Circuit Open?
   │
Yes
   │
Local Cache?
   │
Hit ─────────► Return
   │
Miss
   │
DB Rate Limiter
   │
Allowed?
   │
Yes ─────────► Database
   │
No
   │
Return 503
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why a DB Rate Limiter?
&lt;/h2&gt;

&lt;p&gt;Suppose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal traffic: &lt;strong&gt;100K RPS&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Redis serves &lt;strong&gt;99K RPS&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Database handles &lt;strong&gt;1K RPS&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Redis crashes and every request falls back to the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100K RPS

↓

Database 💥
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, protect the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;circuitBreaker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsOpen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;localCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;localCache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dbRateLimiter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Allow&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="m"&gt;503&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;Only a limited number of requests are allowed to reach the database. The rest fail fast, keeping the system alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where is the DB Rate Limiter?
&lt;/h2&gt;

&lt;p&gt;Not in the API Gateway.&lt;/p&gt;

&lt;p&gt;The gateway only sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /users/123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has no idea whether your service will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query Redis&lt;/li&gt;
&lt;li&gt;Query Postgres&lt;/li&gt;
&lt;li&gt;Call Elasticsearch&lt;/li&gt;
&lt;li&gt;Read Kafka&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;service itself&lt;/strong&gt; knows when it's about to hit the database, so that's where the dependency-specific rate limiter belongs.&lt;/p&gt;

&lt;p&gt;👉 Read: &lt;a href="https://dev.to/daksh-gargas/database-rate-limiting-the-missing-piece-after-a-circuit-breaker-2bp7"&gt;Database Rate Limiting: The Missing Piece After a Circuit Breaker&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A circuit breaker and a rate limiter solve different problems.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Circuit Breaker:&lt;/strong&gt; Stop calling a dependency that is already failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB Rate Limiter:&lt;/strong&gt; Protect the database from being overwhelmed when fallbacks occur.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In production, they almost always work together.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>systemdesign</category>
      <category>backend</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Redis Doesn't Implement "True" LRU</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Sat, 27 Jun 2026 20:15:57 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/why-redis-doesnt-implement-true-lru-1d4n</link>
      <guid>https://dev.to/daksh-gargas/why-redis-doesnt-implement-true-lru-1d4n</guid>
      <description>&lt;p&gt;One question that recently made me rethink cache eviction was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;If Redis uses LRU, why doesn't it maintain a heap (or a perfectly sorted list) of keys?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer comes down to optimizing the common case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Imagine a Redis instance with &lt;strong&gt;10 million keys&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If Redis maintained a perfect LRU structure, every cache hit would need to update it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET user:123

↓

Update LRU ordering

↓

Return value
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even though the lookup is O(1), updating a heap would be O(log n), and maintaining a doubly-linked LRU list would still require modifying shared metadata on &lt;strong&gt;every single read&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For a cache serving millions of requests per second, that's expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redis's Approach: Approximate LRU
&lt;/h2&gt;

&lt;p&gt;Instead of maintaining an exact ordering, Redis uses &lt;strong&gt;random sampling&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When memory is full and a write arrives:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Randomly sample a small number of keys (default: &lt;strong&gt;5&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;Find the least recently used among them.&lt;/li&gt;
&lt;li&gt;Evict that key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At first glance, this seems inaccurate.&lt;/p&gt;

&lt;p&gt;What if all 5 sampled keys are hot?&lt;/p&gt;

&lt;p&gt;It's possible—but statistically very unlikely for most real-world workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Clever Optimization: Eviction Pool
&lt;/h2&gt;

&lt;p&gt;Redis goes one step further.&lt;/p&gt;

&lt;p&gt;Instead of discarding the remaining sampled keys after each eviction, it keeps the best eviction candidates in a small &lt;strong&gt;eviction pool&lt;/strong&gt; (16 entries internally).&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Iteration 1
------------
Sample: A B C D E

Pool:
A B C D E

Evict A

Remaining Pool:
B C D E
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Need more memory?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Iteration 2
------------
Sample:
F G H I J

Merge:
B C D E F G H I J

Keep only the best candidates

Evict the worst one
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pool gradually accumulates better eviction candidates while still sampling only a handful of random keys each iteration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;p&gt;Redis optimizes for the common case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Millions of &lt;strong&gt;GETs&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Relatively few &lt;strong&gt;evictions&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of paying a maintenance cost on every read, Redis does a small amount of work only when memory is exhausted.&lt;/p&gt;

&lt;p&gt;This is a classic systems engineering trade-off:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Accept a near-perfect approximation during rare events to keep the hot path extremely fast.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's one of the reasons Redis continues to scale so well while delivering cache hit rates that are remarkably close to a true LRU implementation.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>systemdesign</category>
      <category>backend</category>
      <category>performance</category>
    </item>
    <item>
      <title>Bloom Filters, Explained Properly</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:03:13 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/bloom-filters-explained-properly-1i6i</link>
      <guid>https://dev.to/daksh-gargas/bloom-filters-explained-properly-1i6i</guid>
      <description>&lt;p&gt;Most explanations throw a wall of theory at you and leave out the one thing that actually makes it click. This post fixes that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one question a bloom filter answers
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this item &lt;strong&gt;definitely not&lt;/strong&gt; in the set, or &lt;strong&gt;possibly&lt;/strong&gt; in it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It can tell you "no" with total certainty, but only ever says "maybe" for yes. It never gives a false negative; it can give a false positive. That asymmetry is the whole point — everything falls out of it. In exchange for the fuzziness, you get tiny memory and blazing speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's made of
&lt;/h2&gt;

&lt;p&gt;Two things: a &lt;strong&gt;bit array&lt;/strong&gt; (a row of light switches, all starting at 0) and a few &lt;strong&gt;hash functions&lt;/strong&gt; (machines that take an item and spit out a position in the array). No stored words. No list of items. Just switches and hash functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many bits does one item take?
&lt;/h2&gt;

&lt;p&gt;This is the question that trips everyone up. When you add &lt;code&gt;cat&lt;/code&gt;, how much room does it take?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactly &lt;code&gt;k&lt;/code&gt; bits — and &lt;code&gt;k&lt;/code&gt; is a number &lt;em&gt;you&lt;/em&gt; pick in advance, not something the word decides.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A word has no natural size. You decide up front, "every item sets exactly 3 bits." That's &lt;code&gt;k&lt;/code&gt;, the same for everything. &lt;code&gt;cat&lt;/code&gt; sets 3 bits, &lt;code&gt;dog&lt;/code&gt; sets 3 bits, &lt;code&gt;elephant&lt;/code&gt; sets 3 bits. Word length is irrelevant — &lt;code&gt;elephant&lt;/code&gt; takes no more room than &lt;code&gt;cat&lt;/code&gt;. &lt;em&gt;Which&lt;/em&gt; bits? That's what the hash functions decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the array
&lt;/h2&gt;

&lt;p&gt;Tiny array of 10 slots, &lt;code&gt;k = 3&lt;/code&gt;. Add &lt;code&gt;cat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hashA("cat") -&amp;gt; 4
hashB("cat") -&amp;gt; 4    &amp;lt;- two hashes can land on the same spot
hashC("cat") -&amp;gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flip those bits on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;index:  0  1  2  3  4  5  6  7  8  9
        .  ■  .  .  ■  .  .  .  .  .
           ^cat        ^cat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cat&lt;/code&gt; lit only &lt;strong&gt;2&lt;/strong&gt; distinct bits even though &lt;code&gt;k = 3&lt;/code&gt;, because two hashes both said 4. Now add &lt;code&gt;dog&lt;/code&gt; (hashes to 7, 1, 9):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;index:  0  1  2  3  4  5  6  7  8  9
        .  ■  .  .  ■  .  .  ■  .  ■
           ^            ^  ^     ^
        cat+dog        cat dog  dog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bit 1 was already lit by &lt;code&gt;cat&lt;/code&gt;; &lt;code&gt;dog&lt;/code&gt; setting it again does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight: you can't read the array
&lt;/h2&gt;

&lt;p&gt;Here's the part that sticks. Look at that final array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        .  ■  .  .  ■  .  .  ■  .  ■
        0  1  2  3  4  5  6  7  8  9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If I handed you this array cold, you could not tell me what's in it.&lt;/strong&gt; You couldn't say "cat and dog are here." All you could say is "switches 1, 4, 7, 9 are on." The array keeps &lt;em&gt;no record of who lit each bit&lt;/em&gt;. A lit bit just means &lt;em&gt;somebody&lt;/em&gt; hashed there — it doesn't remember who, or how many.&lt;/p&gt;

&lt;p&gt;That forgetfulness is the source of everything. It's why the filter is tiny (it's not storing your words, just their smeared fingerprints), and it's why "maybe" can never become "yes."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7u8aescrtmbosb08qotv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7u8aescrtmbosb08qotv.png" alt="Bloom filter: array forming across inserts and a false positive" width="800" height="690"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Querying: same hashes, then an AND
&lt;/h2&gt;

&lt;p&gt;To check &lt;code&gt;cat&lt;/code&gt;, hash it again — same outputs, 4, 4, 1 — and check whether &lt;em&gt;those&lt;/em&gt; bits are all 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Are bits 4 AND 1 both 1?  -&amp;gt; yes -&amp;gt; "possibly present"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's an AND across the item's own target bits. One 0 anywhere is an instant "definitely not" — it doesn't even check the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "No" is certain and "Maybe" isn't
&lt;/h2&gt;

&lt;p&gt;It comes from one fact: &lt;strong&gt;bits only ever turn on, never off.&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;a 0 bit -&amp;gt; "this slot was never touched" -&amp;gt; CERTAIN, nothing erases
a 1 bit -&amp;gt; "someone touched this slot"   -&amp;gt; AMBIGUOUS, the array forgot who
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;0 is proof of absence&lt;/strong&gt;: if a position is still 0, nothing ever hashed there — so any item needing that bit was definitely never added. A &lt;strong&gt;1 is only circumstantial&lt;/strong&gt;: it could be your item, or other items that happened to share that slot. When all an item's bits are 1, you can't tell those apart, so the only honest answer is "maybe."&lt;/p&gt;

&lt;p&gt;This is also why &lt;strong&gt;false negatives are impossible&lt;/strong&gt; — a real member always finds its own bits lit, because it set them and nothing erases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false positive
&lt;/h2&gt;

&lt;p&gt;Query &lt;code&gt;wolf&lt;/code&gt;, never added, but it happens to hash to bits 4 and 1 — both already lit by &lt;code&gt;cat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wolf -&amp;gt; bits 4, 1 -&amp;gt; both 1 -&amp;gt; "maybe present"  -&amp;gt; WRONG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter isn't lying. Its real claim is "every bit &lt;code&gt;wolf&lt;/code&gt; needs is lit" — which is true. It never promised &lt;code&gt;wolf&lt;/code&gt; is present, only that it &lt;em&gt;can't rule it out&lt;/em&gt;. And it genuinely can't: "wolf was added" and "cat was added and wolf collides" produce the identical pattern. The array forgot who lit those bits, remember? So it cannot distinguish them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's useful anyway
&lt;/h2&gt;

&lt;p&gt;A trustworthy "no" lets you skip expensive work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query the filter
  -&amp;gt; "definitely not"  -&amp;gt; skip the expensive lookup (the common case)
  -&amp;gt; "maybe"           -&amp;gt; do the real lookup to confirm (the rare case)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A false positive costs you one wasted lookup, never a wrong final answer — you always verify a "maybe" against the real source. Real uses: &lt;strong&gt;LSM-tree databases&lt;/strong&gt; (Cassandra, RocksDB) put one in front of each on-disk table to skip reads for keys that definitely aren't there; &lt;strong&gt;browser safe-browsing&lt;/strong&gt; keeps a local filter of bad URLs and only phones home on a "maybe."&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff, briefly
&lt;/h2&gt;

&lt;p&gt;More bits and more hash functions lower the false-positive rate, at the cost of memory. You pick how many items you'll store and what error rate you'll tolerate; the array size and number of hash functions fall out of that (the sweet spot lands when the array is about half-full of 1s). And you &lt;strong&gt;can't delete&lt;/strong&gt; from a basic bloom filter — clearing a bit might break other items sharing it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Going deeper.&lt;/strong&gt; The exact sizing math — deriving the false-positive rate &lt;code&gt;(1 − e^(−kn/m))^k&lt;/code&gt;, the optimal &lt;code&gt;m = −(n ln p)/(ln 2)²&lt;/code&gt;, the optimal &lt;code&gt;k = (m/n) ln 2&lt;/code&gt;, and why &lt;code&gt;ln 2&lt;/code&gt; keeps appearing — is laid out clearly in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://en.wikipedia.org/wiki/Bloom_filter" rel="noopener noreferrer"&gt;Wikipedia: Bloom filter&lt;/a&gt; — the standard reference, full derivations&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://llimllib.github.io/bloomfilter-tutorial/" rel="noopener noreferrer"&gt;Bloom Filters by Example&lt;/a&gt; — interactive, lets you watch the array fill&lt;/li&gt;
&lt;li&gt;The original 1970 paper: Burton H. Bloom, &lt;em&gt;Space/Time Trade-offs in Hash Coding with Allowable Errors&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one-paragraph version
&lt;/h2&gt;

&lt;p&gt;A bloom filter is a bit array plus a few hash functions. Adding an item flips the &lt;code&gt;k&lt;/code&gt; bits it hashes to; checking looks at those same bits. Any 0 means definitely-not; all 1s mean probably-yes. The key: you can't read the array — a lit bit doesn't remember who lit it, so a 0 proves absence but a 1 is only circumstantial. That one-way guarantee makes it a perfect cheap gate in front of an expensive lookup: trust the "no," verify the "maybe."&lt;/p&gt;

</description>
      <category>datastructures</category>
      <category>algorithms</category>
      <category>database</category>
      <category>programming</category>
    </item>
    <item>
      <title>Beyond Sub-Agents: One Claude Orchestrating Another (with tmux)</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Wed, 17 Jun 2026 17:46:47 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/beyond-sub-agents-one-claude-orchestrating-another-with-tmux-e1o</link>
      <guid>https://dev.to/daksh-gargas/beyond-sub-agents-one-claude-orchestrating-another-with-tmux-e1o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8uibnwezc5qobbu1wrtu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8uibnwezc5qobbu1wrtu.png" alt="Summary" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A detached tmux session can run completely independently of any visible terminal window. You can send keystrokes into it, capture everything displayed on screen, and attach a human observer at any time.&lt;/p&gt;

&lt;p&gt;In other words, tmux lets you automate an interactive terminal session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Idea
&lt;/h2&gt;

&lt;p&gt;Suppose you have a detached tmux session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can send input into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"echo hello"&lt;/span&gt;
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker Enter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you can read what's on screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux capture-pane &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's essentially it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write → Terminal
Read  ← Terminal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you can write to a terminal and read from it, the terminal becomes programmable.&lt;/p&gt;

&lt;p&gt;Anything that runs inside a terminal can now be controlled programmatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Claude Orchestrating Another
&lt;/h2&gt;

&lt;p&gt;Once you realize a tmux session is programmable, an interesting possibility appears.&lt;/p&gt;

&lt;p&gt;Instead of asking Claude to spawn a sub-agent, you can ask it to create and manage an entirely separate Claude Code session.&lt;/p&gt;

&lt;p&gt;That worker Claude runs independently in its own terminal, while the primary Claude retains the conversation context, understands the broader objective, and remains responsible for planning and decision-making.&lt;/p&gt;

&lt;p&gt;From your perspective, you're still talking to a single Claude.&lt;/p&gt;

&lt;p&gt;Behind the scenes, that Claude is coordinating another fully capable Claude.&lt;/p&gt;

&lt;p&gt;Your primary Claude can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a detached tmux session&lt;/li&gt;
&lt;li&gt;Launch a worker Claude inside it&lt;/li&gt;
&lt;li&gt;Send prompts&lt;/li&gt;
&lt;li&gt;Read responses&lt;/li&gt;
&lt;li&gt;Review results&lt;/li&gt;
&lt;li&gt;Decide what to do next&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The worker Claude isn't a sub-agent.&lt;/p&gt;

&lt;p&gt;It's a completely separate Claude Code process running in its own terminal.&lt;/p&gt;

&lt;p&gt;Meanwhile, the primary Claude retains all of the context, planning, and decision-making.&lt;/p&gt;

&lt;p&gt;You end up with one Claude orchestrating another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use a Sub-Agent?
&lt;/h2&gt;

&lt;p&gt;Sub-agents are great for bounded tasks.&lt;/p&gt;

&lt;p&gt;But sometimes what you really want isn't a sub-agent.&lt;/p&gt;

&lt;p&gt;You want another fully capable Claude Code session.&lt;/p&gt;

&lt;p&gt;The challenge is that you don't want to give up the context, planning, and decision-making already accumulated by your current Claude.&lt;/p&gt;

&lt;p&gt;You want your existing Claude to stay in charge while delegating work to a fully capable worker.&lt;/p&gt;

&lt;p&gt;A tmux-backed Claude worker gives you exactly that.&lt;/p&gt;

&lt;p&gt;The primary Claude remains the orchestrator.&lt;/p&gt;

&lt;p&gt;The worker Claude remains fully capable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal Example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create worker session&lt;/span&gt;
tmux new-session &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; worker

&lt;span class="c"&gt;# Launch Claude&lt;/span&gt;
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_SSE_PORT claude'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  Enter

&lt;span class="c"&gt;# Send prompt&lt;/span&gt;
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"Review this repository"&lt;/span&gt;
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker Enter

&lt;span class="c"&gt;# Read output&lt;/span&gt;
tmux capture-pane &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="nt"&gt;-1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're not talking to Claude through an API.&lt;/p&gt;

&lt;p&gt;You're automating a terminal exactly like a human would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha: Claude Doesn't Launch (and Prompts Don't Submit)
&lt;/h2&gt;

&lt;p&gt;There are two things that will almost certainly trip you up the first time you try this.&lt;/p&gt;

&lt;h3&gt;
  
  
  #1 Nested Claude Sessions Exit Immediately
&lt;/h3&gt;

&lt;p&gt;If you launch Claude from inside an existing Claude Code session, the child process inherits Claude-specific environment variables and immediately exits.&lt;/p&gt;

&lt;p&gt;The fix is to clear the inherited variables before launching the worker:&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;env&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; CLAUDECODE &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-u&lt;/span&gt; CLAUDE_CODE_ENTRYPOINT &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-u&lt;/span&gt; CLAUDE_CODE_SSE_PORT &lt;span class="se"&gt;\&lt;/span&gt;
    claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  #2 &lt;code&gt;send-keys&lt;/code&gt; Doesn't Press Enter
&lt;/h3&gt;

&lt;p&gt;My first attempt looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"Review this repository"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing happened.&lt;/p&gt;

&lt;p&gt;That's because &lt;code&gt;send-keys&lt;/code&gt; only types text into the terminal. It doesn't actually submit the prompt.&lt;/p&gt;

&lt;p&gt;You need to explicitly send the Enter key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"Review this repository"&lt;/span&gt;
tmux send-keys &lt;span class="nt"&gt;-t&lt;/span&gt; worker Enter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you realize that, the interaction model becomes surprisingly simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;send-keys -l&lt;/code&gt; → type text&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Enter&lt;/code&gt; → submit prompt&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;capture-pane&lt;/code&gt; → read output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You're not talking to Claude through an API.&lt;/p&gt;

&lt;p&gt;You're automating a terminal exactly like a human would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Human Observability
&lt;/h2&gt;

&lt;p&gt;A human can attach to the worker at any time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tmux attach &lt;span class="nt"&gt;-t&lt;/span&gt; worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The orchestrator and a human can now observe the exact same Claude session simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't tmux.&lt;/p&gt;

&lt;p&gt;It's realizing that you can ask Claude to create more Claudes, assign them work, review their results, and make decisions based on the outcome.&lt;/p&gt;

&lt;p&gt;After that, &lt;code&gt;send-keys&lt;/code&gt; and &lt;code&gt;capture-pane&lt;/code&gt; are just implementation details.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>agents</category>
      <category>tmux</category>
    </item>
    <item>
      <title>Gmail ate my formatting. So I built PasteClean.</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Wed, 27 May 2026 23:49:28 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/gmail-ate-my-formatting-so-i-built-pasteclean-2i9p</link>
      <guid>https://dev.to/daksh-gargas/gmail-ate-my-formatting-so-i-built-pasteclean-2i9p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fygrucojlb17bifc88fd9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fygrucojlb17bifc88fd9.png" alt="Paste Clean" width="800" height="778"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problem that wouldn't leave me alone: I'd draft something in Notes or ChatGPT — clean formatting, bold headings, blockquotes — copy it, paste it into Gmail's compose window, and watch it fall apart. &lt;/p&gt;

&lt;p&gt;White text on white background. Fonts swapped. Layouts collapsed. Gmail's renderer strips a lot of HTML, and nothing on iOS fixes it cleanly before the paste.&lt;/p&gt;

&lt;p&gt;So I built one.&lt;/p&gt;

&lt;p&gt;PasteClean is an iOS app that sits between your writing and Gmail's compose window:&lt;/p&gt;

&lt;p&gt;→ Write in a rich text editor&lt;br&gt;
→ Preview exactly how it'll look in Gmail (light and dark mode)&lt;br&gt;
→ Copy — PasteClean sanitizes the HTML and puts Gmail-safe markup on your clipboard&lt;br&gt;
→ Paste. Formatting intact.&lt;/p&gt;

&lt;p&gt;It's live on the &lt;a href="https://apps.apple.com/us/app/pasteclean/id6769190276" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;! 🎊&lt;/p&gt;

</description>
      <category>ios</category>
      <category>reactnative</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Google's Workspace CLI returns raw JSON. `gdocs-to-md-mcp` returns markdown. Here's why that matters.</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Tue, 12 May 2026 05:45:17 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/googles-workspace-cli-returns-raw-json-gdocs-to-md-mcp-returns-markdown-heres-why-that-1eh8</link>
      <guid>https://dev.to/daksh-gargas/googles-workspace-cli-returns-raw-json-gdocs-to-md-mcp-returns-markdown-heres-why-that-1eh8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhfly62p9xtroedpyrrww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhfly62p9xtroedpyrrww.png" alt="The problem" width="800" height="658"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Google recently released a &lt;a href="https://github.com/googleworkspace/cli" rel="noopener noreferrer"&gt;Workspace CLI&lt;/a&gt; with MCP support — so technically, your AI assistant &lt;em&gt;can&lt;/em&gt; read a Google Doc now. But what it gets back is raw API JSON: a 500-line nested tree of &lt;code&gt;StructuralElement&lt;/code&gt; objects, &lt;code&gt;ParagraphElement&lt;/code&gt; arrays, and &lt;code&gt;TextRun&lt;/code&gt; objects with style metadata buried three levels deep.&lt;/p&gt;

&lt;p&gt;Your LLM can parse it. But it shouldn't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Markdown Matters for LLMs
&lt;/h2&gt;

&lt;p&gt;Think about what markdown gives an LLM that raw JSON doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;# Heading&lt;/code&gt;&lt;/strong&gt; tells it "this is a new section" — no need to infer structure from nested objects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;`&lt;/strong&gt;bold*&lt;em&gt;`&lt;/em&gt;* signals emphasis — in JSON, that's a &lt;code&gt;textStyle.bold: true&lt;/code&gt; property buried inside a &lt;code&gt;TextRun&lt;/code&gt; object&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;| tables |&lt;/code&gt;&lt;/strong&gt; stay readable inline — in JSON, that's a matrix of &lt;code&gt;TableCell&lt;/code&gt; arrays inside &lt;code&gt;TableRow&lt;/code&gt; arrays inside a &lt;code&gt;Table&lt;/code&gt; object&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;- lists&lt;/code&gt;&lt;/strong&gt; just work — in JSON, you're parsing &lt;code&gt;bullet.nestingLevel&lt;/code&gt; and &lt;code&gt;listProperties.nestingLevels[n].glyphType&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An LLM can read markdown the way a human reads a document. JSON forces it to &lt;em&gt;reconstruct&lt;/em&gt; the document from parts.&lt;/p&gt;

&lt;p&gt;And the research backs this up. An &lt;a href="https://arxiv.org/html/2411.10541v1" rel="noopener noreferrer"&gt;arXiv study&lt;/a&gt; found &lt;strong&gt;up to 40% performance variance&lt;/strong&gt; in LLM output depending on whether the input was plain text, Markdown, JSON, or YAML. A &lt;a href="https://www.improvingagents.com/blog/best-input-data-format-for-llms/" rel="noopener noreferrer"&gt;benchmark testing 11 table formats&lt;/a&gt; showed Markdown-KV hitting &lt;strong&gt;60.7% accuracy&lt;/strong&gt; — 16 points ahead of CSV and JSON alternatives. Markdown is also &lt;a href="https://community.openai.com/t/markdown-is-15-more-token-efficient-than-json/841742" rel="noopener noreferrer"&gt;10-15% more token-efficient&lt;/a&gt; than JSON for the same content, which adds up fast at scale.&lt;/p&gt;

&lt;p&gt;Even the vendors agree: &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt; recommends XML tags with markdown/plain text for document input. &lt;a href="https://developers.openai.com/api/docs/guides/prompt-engineering" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt; recommends markdown formatting for code-related tasks. &lt;a href="https://ai.google.dev/gemini-api/docs/prompting-strategies" rel="noopener noreferrer"&gt;Google Gemini&lt;/a&gt; recommends markdown headings for structuring prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/D4G4/gdocs-to-md-mcp" rel="noopener noreferrer"&gt;&lt;strong&gt;gdocs-to-md-mcp&lt;/strong&gt;&lt;/a&gt; — a local MCP server that fetches Google Docs and converts them to clean markdown. Headings, bold, italic, tables, lists, links — all preserved.&lt;/p&gt;

&lt;p&gt;One command to set up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gdocs-to-md-mcp setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interactive wizard walks you through everything — GCP project selection, OAuth credentials, API enablement, Google sign-in — with clickable links that take you to the exact right page. No hunting through Cloud Console. It even auto-configures Claude Code when it's done.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Looks
&lt;/h2&gt;

&lt;p&gt;Once set up, just paste a Google Docs URL:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Read this doc and summarize the key decisions: &lt;a href="https://docs.google.com/document/d/1abc.../edit" rel="noopener noreferrer"&gt;https://docs.google.com/document/d/1abc.../edit&lt;/a&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude calls &lt;code&gt;read_google_doc&lt;/code&gt;, gets clean structured markdown, and actually understands the document — sections, emphasis, tables, all of it. Compare that to the raw &lt;code&gt;\n\n&lt;/code&gt;-delimited text blob from the default Google Drive integration. Night and day.&lt;/p&gt;

&lt;p&gt;Three tools, nothing more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;read_google_doc&lt;/code&gt;&lt;/strong&gt; — URL or doc ID in, markdown out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;search_google_docs&lt;/code&gt;&lt;/strong&gt; — find docs by keyword&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;list_recent_docs&lt;/code&gt;&lt;/strong&gt; — see what's been updated recently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No 80-tool mega-server. No Gmail. No Calendar. Just Google Docs to markdown, done right.&lt;/p&gt;

&lt;p&gt;Install globally with &lt;code&gt;npm install -g gdocs-to-md-mcp&lt;/code&gt; or just run it directly with &lt;code&gt;npx&lt;/code&gt; — your call.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/D4G4/gdocs-to-md-mcp" rel="noopener noreferrer"&gt;github.com/D4G4/gdocs-to-md-mcp&lt;/a&gt;&lt;br&gt;
npm: &lt;a href="https://www.npmjs.com/package/gdocs-to-md-mcp" rel="noopener noreferrer"&gt;npmjs.com/package/gdocs-to-md-mcp&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;[Update]&lt;/strong&gt;: Simon Willison recently wrote about the &lt;a href="https://simonwillison.net/2026/May/8/unreasonable-effectiveness-of-html/" rel="noopener noreferrer"&gt;unreasonable effectiveness of HTML for LLMs&lt;/a&gt;, which might make you wonder — should this tool output HTML instead?&lt;/p&gt;

&lt;p&gt;The key distinction: Simon's argument is about LLM output — when you ask an LLM to explain something back to you, HTML is richer (SVGs, interactive widgets, navigation). That's a presentation win.&lt;/p&gt;

&lt;p&gt;Our tool solves the opposite direction — feeding content into the LLM. For input comprehension, the research still favors markdown: 40% better performance, 15% fewer tokens, and all three major vendors recommend it for document input.&lt;/p&gt;

&lt;p&gt;tl;dr: Markdown for what goes in. HTML for what comes out. Different problems, different formats.&lt;/p&gt;




&lt;h4&gt;
  
  
  Sources: Why Markdown &amp;gt; JSON for LLM Document Comprehension
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/html/2411.10541v1" rel="noopener noreferrer"&gt;Does Prompt Formatting Have Any Impact on LLM Performance?&lt;/a&gt; — Up to 40% performance variance across formats&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/html/2501.15000v1" rel="noopener noreferrer"&gt;MDEval: Evaluating Markdown Awareness in LLMs&lt;/a&gt; — 20K-instance benchmark on LLM markdown comprehension&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.improvingagents.com/blog/best-input-data-format-for-llms/" rel="noopener noreferrer"&gt;Which Table Format Do LLMs Understand Best?&lt;/a&gt; — Markdown-KV at 60.7% accuracy, 16 points ahead of CSV/JSON&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://community.openai.com/t/markdown-is-15-more-token-efficient-than-json/841742" rel="noopener noreferrer"&gt;Markdown is 15% more token efficient than JSON&lt;/a&gt; — Real-world token comparison on identical content&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aider.chat/2024/08/14/code-in-json.html" rel="noopener noreferrer"&gt;LLMs are bad at returning code in JSON&lt;/a&gt; — All tested models performed significantly worse with JSON&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices" rel="noopener noreferrer"&gt;Anthropic — Claude Prompting Best Practices&lt;/a&gt; — Recommends XML + markdown/plain text for document input&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/prompt-engineering" rel="noopener noreferrer"&gt;OpenAI — Prompt Engineering Guide&lt;/a&gt; — Recommends markdown formatting for code tasks&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ai.google.dev/gemini-api/docs/prompting-strategies" rel="noopener noreferrer"&gt;Google Gemini — Prompt Design Strategies&lt;/a&gt; — Recommends markdown headings for structuring prompts&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mcp</category>
      <category>claude</category>
      <category>markdown</category>
      <category>developertools</category>
    </item>
    <item>
      <title>I Built a Smarter 20-20-20 App Because Every Other One Annoyed Me</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Sun, 10 May 2026 21:09:46 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/i-built-a-smarter-20-20-20-app-because-every-other-one-annoyed-me-533o</link>
      <guid>https://dev.to/daksh-gargas/i-built-a-smarter-20-20-20-app-because-every-other-one-annoyed-me-533o</guid>
      <description>&lt;p&gt;
  &lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm4qer003ai4zqyhm5nlt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm4qer003ai4zqyhm5nlt.png" alt="Blink Icon" width="800" height="800"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;20-20-20 rule&lt;/strong&gt; is a simple habit recommended by optometrists to reduce eye strain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every 20 minutes, look at something 20 feet away for 20 seconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The problem is that most 20-20-20 apps are so annoying that I end up disabling them within a week.&lt;/p&gt;

&lt;p&gt;They interrupt you mid-thought.&lt;/p&gt;

&lt;p&gt;They popup during meetings.&lt;/p&gt;

&lt;p&gt;They remind you even if you just stepped away from your desk.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://blink20.net/" rel="noopener noreferrer"&gt;Blink&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of blindly firing timers, Blink tries to understand context:&lt;/p&gt;

&lt;p&gt;🧠 in flow? it waits for a natural pause&lt;br&gt;
🚶 away from your desk? timer resets automatically&lt;br&gt;
🎙️ in a meeting? timer pauses&lt;br&gt;
🤖 waiting for AI output? timer keeps running&lt;/p&gt;

&lt;p&gt;The goal was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;build a break reminder smart enough that you forget it's running until the exact moment you need it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Blink needs Accessibility permissions to work — which is a pretty big trust ask.&lt;/p&gt;

&lt;p&gt;That’s why it’s fully open source.&lt;/p&gt;

&lt;p&gt;You can see exactly what it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reads input timing, never content&lt;/li&gt;
&lt;li&gt;no analytics&lt;/li&gt;
&lt;li&gt;no telemetry&lt;/li&gt;
&lt;li&gt;no network calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything happens locally, and every line of code is public.&lt;/p&gt;

&lt;p&gt;Once we squash the remaining bugs, I’ll push it to the App Store.&lt;/p&gt;

&lt;p&gt;If you try it out, I’d love feedback.&lt;/p&gt;

&lt;p&gt;And if this sounds interesting to you, contributions are very welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download now :D
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://blink20.net" rel="noopener noreferrer"&gt;https://blink20.net&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/D4G4/blink" rel="noopener noreferrer"&gt;https://github.com/D4G4/blink&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>health</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>Save those $$ GitHub Actions Minutes: How We Built a Commit-Driven CI System for iOS</title>
      <dc:creator>Daksh Gargas</dc:creator>
      <pubDate>Thu, 23 Apr 2026 17:34:47 +0000</pubDate>
      <link>https://dev.to/daksh-gargas/stop-wasting-github-actions-minutes-how-we-built-a-commit-driven-ci-system-for-ios-47cd</link>
      <guid>https://dev.to/daksh-gargas/stop-wasting-github-actions-minutes-how-we-built-a-commit-driven-ci-system-for-ios-47cd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwh3zlk54hm2j7tejdqpn.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwh3zlk54hm2j7tejdqpn.jpeg" alt="Image 1: Hero — commit message drives CI routing" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building an iOS app with GitHub Actions, you're probably burning through macOS runner minutes like they're free. &lt;/p&gt;

&lt;p&gt;Spoiler: they're not — macOS runners cost &lt;strong&gt;10x&lt;/strong&gt; more than Linux runners, and a 25-minute test run that fires on every push adds up fast.&lt;/p&gt;

&lt;p&gt;We run a Swift/SwiftUI app with 3000+ tests across BLE integration, calibration logic, snapshot testing, and more. Here's how we went from "run everything on every push" to an opt-in, routable, self-hosted-friendly CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR — you can get the speed of self-hosted runners without the usual operational overhead, and in a way that's completely developer-independent:&lt;/strong&gt; &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No shared build box to maintain, &lt;/li&gt;
&lt;li&gt;no daemons running on anyone's laptop, &lt;/li&gt;
&lt;li&gt;no hardcoded machine names in CI configs. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every developer's Mac is self-aware: it reads its own identity from the runner config on disk, auto-starts in single-job mode only when a commit explicitly asks for it, and shuts down the moment the job finishes. One line in a commit message routes the build to the right machine — and the same workflow works unchanged whether you're running on GitHub's cloud or on someone's M-series laptop.&lt;/p&gt;

&lt;p&gt;The rest of this post walks through how we got there: a commit-driven CI system where &lt;strong&gt;the commit message controls exactly what runs, where it runs, and whether it runs at all&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Our test suite takes ~25 minutes on GitHub-hosted macOS runners — and that's not even running all the tests, just a subset. Most of that time is build time; the actual tests finish in seconds. But every push triggered that same partial suite, even for a one-line copy change.&lt;/p&gt;

&lt;p&gt;We were spending hundreds of dollars a month on CI that mostly told us "yes, the code you didn't touch still works."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqavuu4er53cvkqqiypy6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqavuu4er53cvkqqiypy6.png" alt="Image 2: Before vs After — 25 min on every push vs 0–3 min opt-in" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: &lt;code&gt;[ci: ...]&lt;/code&gt; Commit Directives
&lt;/h2&gt;

&lt;p&gt;We put CI control directly in the commit message body. One line, declarative, readable in &lt;code&gt;git log&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feat(theme): update color palette

[ci: tags=theme exclude=snapshot]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. This commit runs only the &lt;code&gt;theme&lt;/code&gt;-tagged tests and skips snapshot tests. Total CI time: ~3 minutes instead of 25.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Directive Syntax
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ci: tags=&amp;lt;t1,t2&amp;gt; exclude=&amp;lt;t1,t2&amp;gt; runner=&amp;lt;name&amp;gt; final record-snapshots]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every key is optional:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags=theme,calibration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run only these Swift Testing tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exclude=snapshot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip these tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;final&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run the full test suite (pre-merge gate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;runner=&amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Route to a self-hosted runner (dynamically resolved — see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;record-snapshots&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-record snapshot reference images&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;No directive = no CI run.&lt;/strong&gt; Normal development commits don't burn any minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Parsing Works
&lt;/h2&gt;

&lt;p&gt;Both our workflows (&lt;code&gt;targeted-tests.yml&lt;/code&gt; for scoped runs, &lt;code&gt;regular-tests.yml&lt;/code&gt; for full suite) share a &lt;code&gt;parse-directive&lt;/code&gt; job that runs on a cheap &lt;code&gt;ubuntu-latest&lt;/code&gt; runner. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checks out the repo (needs git history)&lt;/li&gt;
&lt;li&gt;Reads the latest non-merge commit message&lt;/li&gt;
&lt;li&gt;Extracts the &lt;code&gt;[ci: ...]&lt;/code&gt; block with a simple grep/sed pipeline&lt;/li&gt;
&lt;li&gt;Outputs structured values (&lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;exclude&lt;/code&gt;, &lt;code&gt;runner-name&lt;/code&gt;, etc.) for downstream jobs
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Parse [ci: ...] block from commit message&lt;/span&gt;
&lt;span class="nv"&gt;CI_BLOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'\[ci:[^]]+\]'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CI_BLOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'tags=[^ ]+'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/tags=//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RUNNER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CI_BLOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'runner=[^ ]+'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/runner=//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expensive macOS jobs only start if the parse job says so. If there's no directive, the workflow exits cleanly with a green check — no wasted minutes, no red X.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tag-Based Test Scoping with Swift Testing
&lt;/h2&gt;

&lt;p&gt;Swift Testing's &lt;code&gt;@Test(.tags(...))&lt;/code&gt; system makes this possible. Every test is tagged by feature area:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@Test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calibration&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;calibrationConvergesWithinTolerance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;@Test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bluetoothManager&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;connectDisconnectCycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our test runner script translates comma-separated tags into &lt;code&gt;xcodebuild&lt;/code&gt; flags:&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="c"&gt;# tags=calibration,homePage becomes:&lt;/span&gt;
xcodebuild &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-only-testing-tags&lt;/span&gt; calibration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-only-testing-tags&lt;/span&gt; homePage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means a developer working on calibration only runs calibration tests. A theme change only runs theme tests. The feedback loop goes from 25 minutes to under 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Hosted Runners: Your Machine, Your Speed ⚡
&lt;/h2&gt;

&lt;p&gt;GitHub-hosted macOS runners are decent machines, but your M-series MacBook Pro is probably faster — especially since it already has a warm DerivedData cache and resolved SPM packages.&lt;/p&gt;

&lt;p&gt;We added a &lt;code&gt;runner=&amp;lt;name&amp;gt;&lt;/code&gt; directive that routes the CI job to a specific self-hosted runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fix(ble): stabilize BLE tests

[ci: tags=bluetoothManager,bptManager runner=daksh-personal]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But how does a developer — or a Claude Code agent composing a commit — know what name to use? They don't hardcode it. We wrote a tiny helper script:&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;scripts/ci/runner-name.sh
daksh-personal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads the name from the runner's own config file (more on that below). So a commit looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix(ble): stabilize tests

[ci: tags=bluetoothManager runner=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/ci/runner-name.sh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shell substitutes the real name at commit time. No one memorizes anything, and AI agents use the same script.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Each developer registers their Mac as a GitHub Actions self-hosted runner, picking any name they want (&lt;code&gt;daksh-personal&lt;/code&gt;, &lt;code&gt;janes-studio&lt;/code&gt;, &lt;code&gt;build-mac-01&lt;/code&gt; — whatever) and adding that name as a runner label&lt;/li&gt;
&lt;li&gt;When you run &lt;code&gt;./config.sh&lt;/code&gt;, the GitHub Actions runner writes a &lt;code&gt;.runner&lt;/code&gt; JSON file to the runner directory. This is a standard part of the runner infrastructure — we didn't create it. It looks like this:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"agentId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"agentName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"daksh-personal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"poolId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"poolName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"serverUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://pipelines..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"gitHubUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/your-org/your-repo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"workFolder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"_work"&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;agentName&lt;/code&gt; field is whatever you typed at the "Enter the name of the runner" prompt. Both &lt;code&gt;runner-name.sh&lt;/code&gt; and the post-push hook read it dynamically — &lt;strong&gt;nothing is hardcoded in CI configs or documentation&lt;/strong&gt;:&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="c"&gt;# runner-name.sh — prints the local runner name&lt;/span&gt;
   python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
     import json
     config = open('actions-runner/.runner', 'rb').read().decode('utf-8-sig')
     print(json.loads(config)['agentName'])
   "&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You set the name once during setup and never think about it again. Each machine knows its own identity.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The workflow uses a dynamic &lt;code&gt;runs-on&lt;/code&gt; — if the commit says &lt;code&gt;runner=daksh-personal&lt;/code&gt;, the job lands on exactly that machine:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.runner-name != '' &amp;amp;&amp;amp; inputs.runner-name || 'macos-26' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;On self-hosted runners, we skip &lt;code&gt;setup-xcode&lt;/code&gt; and cache steps (unnecessary — everything's already there)&lt;/li&gt;
&lt;li&gt;A post-push hook automatically starts the runner in single-job mode (&lt;code&gt;./run.sh --once&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The runner picks up one job, runs it, and exits. No permanently running service. No wasted resources when you're not using it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Auto-Start Hook — This Is Where It Gets Magical
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fthoj1yo5tjhxcdxgl1iu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fthoj1yo5tjhxcdxgl1iu.png" alt="Image 3: Auto-start hook flow — git push → parse → ./run.sh --once → job picked up" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the part that feels like cheating.&lt;/p&gt;

&lt;p&gt;A self-hosted runner is useless if you have to remember to start it. "Let me open a terminal, &lt;code&gt;cd&lt;/code&gt; into the runner directory, run &lt;code&gt;./run.sh --once&lt;/code&gt;, wait for the job, then Ctrl-C" — nobody's doing that twenty times a day. The whole value proposition collapses the moment it requires manual effort.&lt;/p&gt;

&lt;p&gt;So we made it disappear. A Claude Code hook (checked into &lt;code&gt;.claude/settings.json&lt;/code&gt;) fires automatically after every &lt;code&gt;git push&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"if"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./scripts/ci/start-self-hosted-runner.sh"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script does three things, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reads the last commit message&lt;/strong&gt; and extracts the &lt;code&gt;runner=&amp;lt;name&amp;gt;&lt;/code&gt; field from the &lt;code&gt;[ci: ...]&lt;/code&gt; block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads the local runner name from &lt;code&gt;actions-runner/.runner&lt;/code&gt;&lt;/strong&gt; — a JSON config file written once during &lt;code&gt;./config.sh&lt;/code&gt; setup — and checks if it matches the directive. If this machine isn't the target (or there's no directive at all), it exits silently. No noise, no side effects. Every developer's machine is self-aware: the script doesn't need to know who you are, it reads the identity from the runner config that already exists on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If this machine &lt;em&gt;is&lt;/em&gt; the target&lt;/strong&gt;, it launches the runner in background, single-job mode: &lt;code&gt;./run.sh --once &amp;amp;&lt;/code&gt;. The runner registers with GitHub, picks up exactly one job, executes it, and exits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the entire interaction. You write a commit message with &lt;code&gt;runner=&amp;lt;your-runner-name&amp;gt;&lt;/code&gt;, you push, and by the time you've switched back to your editor, your laptop is already building. The feedback loop for BLE tests went from &lt;strong&gt;~25 minutes (cloud runner cold start + build + test)&lt;/strong&gt; to &lt;strong&gt;~30 seconds (warm cache, already-resolved SPM packages, M-series silicon)&lt;/strong&gt;. A 50x speedup, triggered by a line in a commit message.&lt;/p&gt;

&lt;p&gt;And because it's &lt;code&gt;--once&lt;/code&gt;, there's no daemon, no background service, no "did I remember to stop the runner?" It's entirely demand-driven: it exists only while your job needs it.&lt;/p&gt;

&lt;p&gt;The hook is the glue that makes the rest of the system feel invisible. Without it, self-hosted runners are a clever-but-annoying option. With it, they're the default path for anything hardware-adjacent — and you stop thinking about CI altogether. You just commit, push, and the right machine runs the right tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Build-Once, Test-Many Pattern
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8f47589t8ph02yebdikz.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8f47589t8ph02yebdikz.jpeg" alt="Image 4: Build once, fan out to parallel Logic + Snapshot test jobs" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the full test suite (&lt;code&gt;[ci: final]&lt;/code&gt;), we don't want to build the project three times. Our &lt;code&gt;regular-tests.yml&lt;/code&gt; workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build job&lt;/strong&gt;: Compiles once, packages DerivedData as an artifact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic tests job&lt;/strong&gt;: Downloads the artifact, runs &lt;code&gt;xcodebuild test-without-building&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot tests job&lt;/strong&gt;: Same artifact, runs only snapshot-tagged tests&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Jobs 2 and 3 run in parallel. Total wall time is build + max(logic, snapshots) instead of build * 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Self-Hosted Runners Are Even Faster: Warm DerivedData
&lt;/h3&gt;

&lt;p&gt;On GitHub-hosted runners, every job starts clean — no DerivedData, no resolved SPM packages. The build job has to compile everything from scratch every time. On a self-hosted runner, DerivedData persists in &lt;code&gt;$HOME/DerivedData/CI&lt;/code&gt; between CI runs. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPM packages stay resolved.&lt;/strong&gt; No re-downloading, no re-linking. The &lt;code&gt;-skipPackageUpdates&lt;/code&gt; flag in quick mode skips the resolution step entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental builds.&lt;/strong&gt; If you changed one file, xcodebuild recompiles that file — not the entire project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build-graph validation, not recompilation.&lt;/strong&gt; Test jobs run &lt;code&gt;build-for-testing&lt;/code&gt; before &lt;code&gt;test-without-building&lt;/code&gt; to validate that the build products are still valid. This takes 65–90 seconds — not zero, but far less than a cold build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We measured this over 5 consecutive CI runs on the same self-hosted runner:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Build job&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 (cold-ish)&lt;/td&gt;
&lt;td&gt;3m 1s&lt;/td&gt;
&lt;td&gt;First run after the DerivedData path fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1m 6s&lt;/td&gt;
&lt;td&gt;Warm cache — SPM resolved, most objects cached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1m 44s&lt;/td&gt;
&lt;td&gt;Small code change, incremental recompile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1m 44s&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;1m 39s&lt;/td&gt;
&lt;td&gt;Consistent ~1.5 min steady state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first run pays the cold tax. Every subsequent run benefits from the warm cache. On GitHub-hosted runners, every run is Run 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Safety Net: When We Still Run Everything
&lt;/h2&gt;

&lt;p&gt;To be clear — we're not skipping tests, we're &lt;em&gt;scheduling&lt;/em&gt; them. The full suite is still the source of truth, and it absolutely runs at the moments that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before a PR merges.&lt;/strong&gt; A &lt;code&gt;[ci: final]&lt;/code&gt; commit (or the equivalent on the merge commit) runs the entire suite as the pre-merge gate. Nothing lands on &lt;code&gt;main&lt;/code&gt; without it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a regular cadence&lt;/strong&gt; for long-lived branches, so drift doesn't pile up silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always before an App Store submission.&lt;/strong&gt; Shipping to users is the one place where "fast feedback" loses to "zero surprises" — the full suite runs, snapshots and all, no exceptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point of commit directives isn't to avoid testing. It's to find the middle ground between rapid iteration and stability: don't pay the 25-minute tax on a typo fix, &lt;em&gt;do&lt;/em&gt; pay it when the blast radius justifies it. CI is still the source of truth — we're just choosing when to consult it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Average CI time per push&lt;/td&gt;
&lt;td&gt;25 min&lt;/td&gt;
&lt;td&gt;0-3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly macOS runner minutes&lt;/td&gt;
&lt;td&gt;~2,000&lt;/td&gt;
&lt;td&gt;~300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to BLE test feedback&lt;/td&gt;
&lt;td&gt;25 min (cloud)&lt;/td&gt;
&lt;td&gt;~30s (self-hosted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commits that trigger CI&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build job (self-hosted, warm)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;~1.5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build job (cloud, cold)&lt;/td&gt;
&lt;td&gt;~3 min&lt;/td&gt;
&lt;td&gt;~3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full suite wall time (self-hosted)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;~13 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key insight: &lt;strong&gt;most commits don't need CI at all.&lt;/strong&gt; When they do, they rarely need &lt;em&gt;all&lt;/em&gt; the tests. And when you need fast feedback on hardware-adjacent code (BLE, sensors), your own machine is 50x faster than waiting for a cloud runner to boot, build, and test.&lt;/p&gt;

&lt;p&gt;The second insight: &lt;strong&gt;DerivedData persistence is the real speedup on self-hosted.&lt;/strong&gt; The build-once-test-many pattern saves one redundant build, but the warm DerivedData cache across CI runs saves the SPM resolution and cold compilation that dominates cloud runner time. A self-hosted build job consistently finishes in ~1.5 minutes versus ~3 minutes on a cold cloud runner — and that gap widens as your dependency graph grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;You don't need our exact setup. The pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tag your tests&lt;/strong&gt; by feature area (Swift Testing, pytest markers, Jest tags — whatever your framework supports)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse the commit message&lt;/strong&gt; in a cheap Linux job before spinning up expensive runners&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default to not running&lt;/strong&gt; — opt-in is cheaper than opt-out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let devs use their own machines&lt;/strong&gt; for fast iteration via self-hosted runners in single-job mode — read the runner identity from the local config so nothing is hardcoded per-developer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The commit message is the interface. It's visible in &lt;code&gt;git log&lt;/code&gt;, reviewable in PRs, and doesn't require any dashboard or config file changes. Just write your message and push.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Need After Cloning the Repo
&lt;/h2&gt;

&lt;p&gt;The CI directives (&lt;code&gt;[ci: tags=...]&lt;/code&gt;, &lt;code&gt;[ci: final]&lt;/code&gt;) work out of the box — they're parsed by GitHub Actions workflows already in the repo. But if you want to use &lt;code&gt;runner=&amp;lt;name&amp;gt;&lt;/code&gt; to run tests on your own machine, here's the one-time setup:&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Self-hosted runner setup (one-time, ~5 min)
  &lt;h3&gt;
  
  
  1. Install the GitHub Actions runner
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your-project/..   &lt;span class="c"&gt;# parent of the repo&lt;/span&gt;
&lt;span class="nb"&gt;mkdir &lt;/span&gt;actions-runner &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;actions-runner

&lt;span class="c"&gt;# Go to repo Settings → Actions → Runners → "New self-hosted runner"&lt;/span&gt;
&lt;span class="c"&gt;# Select macOS / ARM64, then follow the download + extract instructions:&lt;/span&gt;
curl &lt;span class="nt"&gt;-o&lt;/span&gt; actions-runner-osx-arm64-X.Y.Z.tar.gz &lt;span class="nt"&gt;-L&lt;/span&gt; &amp;lt;URL_FROM_GITHUB&amp;gt;
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf ./actions-runner-osx-arm64-X.Y.Z.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  2. Configure the runner
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./config.sh &lt;span class="nt"&gt;--url&lt;/span&gt; https://github.com/your-org/your-repo &lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;TOKEN_FROM_SETTINGS_PAGE&amp;gt;
&lt;span class="c"&gt;# Pick any name you want (e.g. "daksh-personal", "janes-studio")&lt;/span&gt;
&lt;span class="c"&gt;# This name gets written to .runner and is what you'll use in commit messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  3. Add your runner name as a label
&lt;/h3&gt;

&lt;p&gt;This is the step you'll miss the first time. GitHub's &lt;code&gt;runs-on&lt;/code&gt; matches &lt;strong&gt;labels&lt;/strong&gt;, not runner names — and &lt;code&gt;./config.sh&lt;/code&gt; only assigns generic labels (&lt;code&gt;self-hosted&lt;/code&gt;, &lt;code&gt;macOS&lt;/code&gt;, &lt;code&gt;ARM64&lt;/code&gt;). You need to add your runner name as a custom label:&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;cd&lt;/span&gt; /path/to/your-repo
&lt;span class="nv"&gt;RUNNER_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/ci/runner-name.sh&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RUNNER_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh api repos/your-org/your-repo/actions/runners &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s2"&gt;".runners[] | select(.name==&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$RUNNER_NAME&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;) | .id"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
gh api &lt;span class="nt"&gt;-X&lt;/span&gt; POST repos/your-org/your-repo/actions/runners/&lt;span class="nv"&gt;$RUNNER_ID&lt;/span&gt;/labels &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--input&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;labels&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$RUNNER_NAME&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. That's it
&lt;/h3&gt;

&lt;p&gt;Everything else is already in the repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/strong&gt; — a post-push hook that auto-starts the runner when your commit includes &lt;code&gt;runner=&amp;lt;your-name&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scripts/ci/runner-name.sh&lt;/code&gt;&lt;/strong&gt; — reads your runner name from the local config so you never hardcode it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scripts/ci/start-self-hosted-runner.sh&lt;/code&gt;&lt;/strong&gt; — matches the commit directive against the local runner and starts it in single-job mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your first self-hosted CI run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix(ble): stabilize

[ci: tags=bluetoothManager runner=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/ci/runner-name.sh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
git push
&lt;span class="c"&gt;# Hook fires → runner starts → picks up the job → exits when done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No daemon, no background service, no config files to edit. Clone, configure once, push.&lt;/p&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Your Take?
&lt;/h2&gt;

&lt;p&gt;This is the setup that worked for &lt;em&gt;us&lt;/em&gt; — a small team, an iOS app, a specific test suite. But I'm genuinely curious how other teams are solving the same problem. What tradeoffs did you make that we didn't? What's broken about this approach that I'm not seeing?&lt;/p&gt;

&lt;p&gt;Some things I'd love POV on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Path-based triggers vs commit directives&lt;/strong&gt; — we picked commits because they're explicit and reviewable, but &lt;code&gt;paths:&lt;/code&gt; filters are simpler. When has one clearly beaten the other for you?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted runners at scale&lt;/strong&gt; — we have a handful of developer Macs. Does this pattern hold up with 20+ engineers, or does it fall apart on coordination?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "no CI by default" call&lt;/strong&gt; — is this reckless on a larger team, or is the pre-merge gate enough of a safety net?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something we haven't even considered&lt;/strong&gt; — Bazel remote cache? Merge queues? Monorepo-style affected-test detection? Tell me what we're missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's improve this together so anyone reading it later walks away with the best possible playbook — not just ours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We're building a health-tech companion app at &lt;a href="//www.dls.co"&gt;Denver Life Sciences&lt;/a&gt;. If you have questions about this setup or want to see the workflow files, drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>ios</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
