<?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: Nazar Boyko</title>
    <description>The latest articles on DEV Community by Nazar Boyko (@nazar-boyko).</description>
    <link>https://dev.to/nazar-boyko</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1875383%2F1b3f5dc9-df1c-4551-9f6e-e3b6234b3d6c.gif</url>
      <title>DEV Community: Nazar Boyko</title>
      <link>https://dev.to/nazar-boyko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nazar-boyko"/>
    <language>en</language>
    <item>
      <title>Here Lies My Toaster</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Sun, 01 Nov 2026 02:55:51 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/here-lies-my-toaster-1nmm</link>
      <guid>https://dev.to/nazar-boyko/here-lies-my-toaster-1nmm</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every house has a drawer of dead things: the earbuds that play on one side, the charger with the split cable, the phone with the spiderweb screen. Nobody mourns them. Halloween ends tonight and Día de los Muertos begins tomorrow, two days for remembering the departed, so I gave the drawer an obituary page.&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%2F1ounlrcjfat84wdjfl3e.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%2F1ounlrcjfat84wdjfl3e.png" alt="Obituary card from The Household Gazette for Silas Gourdington III, three carved pumpkins on a porch, the smallest one collapsed. Cause of death: visible patches of rot." width="800" height="2153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here Lies is a small web app that runs on your own laptop. You take a photo of something that died, and Gemma 4, running through Ollama on the same machine, writes it a straight-faced newspaper obituary: a name, a cause of death, a life of service, who it leaves behind, a request in lieu of flowers, an epitaph. You can download the card as a PNG.&lt;/p&gt;

&lt;p&gt;The rule: the model may invent a name. It may not invent a scratch. Every physical detail on the card has to point at something the model listed when it looked at the photo, and plain Go code checks that before you see it.&lt;/p&gt;

&lt;p&gt;Living things get a notice instead.&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%2Fxf24komloyrug3641gnh.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%2Fxf24komloyrug3641gnh.png" alt="Notice from The Household Gazette: Still alive. Give it a treat instead." width="794" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my tests the cat, the dog, the monstera and the person were turned away every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;More cards on the landing page: &lt;a href="https://nazboyko.github.io/here-lies/" rel="noopener noreferrer"&gt;https://nazboyko.github.io/here-lies/&lt;/a&gt;&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%2Foar3rdkze36jxpklsyyk.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%2Foar3rdkze36jxpklsyyk.png" alt="Obituary card for Mr. Chester Golden-Crust, two slices of burnt toast" width="800" height="2079"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The toaster survived. Mr. Chester Golden-Crust did not: "In lieu of flowers, please wipe the crumbs from beneath the toaster tray."&lt;/em&gt;&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%2F4fj7u0nvtwul3bokiple.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%2F4fj7u0nvtwul3bokiple.png" alt="Obituary card for Agnes Petalbrook, a rotting succulent in a terracotta pot" width="800" height="2103"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Agnes Petalbrook: "Survived by a terracotta shard and the faint smell of potting mix."&lt;/em&gt;&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%2Funb7fe8rvxwtjn3xnbrx.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%2Funb7fe8rvxwtjn3xnbrx.png" alt="Obituary card for Mr. Arthur Glowwick, a scorched light bulb lying in ash" width="800" height="1957"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Mr. Arthur Glowwick: "It shone when asked, and dimmed when done."&lt;/em&gt;&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%2F5ltim9g7h6zj67ov5ol8.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%2F5ltim9g7h6zj67ov5ol8.png" alt="The app: the toast's obituary card on the left, and on the right the list of details the model saw, each marked with the sentence that used it" width="800" height="925"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The app, on another run of the same toast. On the right: what the model saw, and which sentence used each detail.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run it yourself. You need Ollama, Go 1.26 and Node 20 or newer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma4:e4b
git clone https://github.com/nazboyko/here-lies &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;here-lies
make run
open http://localhost:8787
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/nazboyko" rel="noopener noreferrer"&gt;
        nazboyko
      &lt;/a&gt; / &lt;a href="https://github.com/nazboyko/here-lies" rel="noopener noreferrer"&gt;
        here-lies
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Obituaries for the things that died in your house, written by a local open-weight model.
    &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;Here Lies&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Obituaries for the things that died in your house, written by a model on your own laptop.&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/here-lies/docs/img/app-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fhere-lies%2FHEAD%2Fdocs%2Fimg%2Fapp-desktop.png" alt="The app with an obituary card for two slices of burnt toast, next to the list of details the model saw"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Take a photo of something that died: a cracked phone, burnt toast, shoes with holes, last week's pumpkin. Gemma 4 runs on your computer through Ollama, looks at the photo and writes the thing a straight-faced newspaper obituary. The model may invent a name. It may not invent a scratch: every physical detail on the card has to point at something the model listed when it looked at the photo, and plain Go code checks that before you see it.&lt;/p&gt;

&lt;p&gt;Point it at a cat, a person or a healthy plant and it refuses: "Still alive. Give it a treat instead."&lt;/p&gt;

&lt;p&gt;Landing page: &lt;a href="https://nazboyko.github.io/here-lies/" rel="nofollow noopener noreferrer"&gt;https://nazboyko.github.io/here-lies/&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run it&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;You need &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;, Go 1.26 and Node 20 or newer.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;ollama pull gemma4:e4b
git clone https://github.com/nazboyko/here-lies &amp;amp;&amp;amp; cd here-lies
make run
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then open &lt;a href="http://localhost:8787" rel="nofollow noopener noreferrer"&gt;http://localhost:8787&lt;/a&gt;…&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/nazboyko/here-lies" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&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%2F5p5phk3x9nhjg1wt7rq5.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%2F5p5phk3x9nhjg1wt7rq5.png" alt="Pipeline: 1 Look, Gemma 4 sees the photo. 2 Gate, plain code. 3 Write, Gemma 4 with text only. 4 Check, plain code." width="799" height="311"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Look.&lt;/strong&gt; Gemma 4 gets the photo and returns JSON: thing, person, animal, living plant or unclear; what the object is; and what it sees, in three lists: damage, wear, features. Code numbers the details d1, d2, d3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate.&lt;/strong&gt; Plain code. People, animals and healthy plants get the still-alive notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write.&lt;/strong&gt; A second Gemma call, text only. It turns the numbered list into an obituary in JSON, and every sentence about the body carries the ids it uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check.&lt;/strong&gt; Plain Go, no model. The cause of death must cite a real damage or wear id, every life sentence must cite a real id, and everything must fit the column. A failing draft goes back once with the problems listed. If the rewrite fails too, code keeps what passes, rebuilds the rest from the detail texts, and the page says what it repaired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why two calls? The writer never sees the photo. It can only describe what the looking step wrote down, and because each reference is an id, my code can verify it. One call doing both would hand me prose I could either trust or not.&lt;/p&gt;

&lt;p&gt;To measure it, I ran 17 photos from Wikimedia Commons through the real pipeline: 11 dead things, a cat, a dog, a healthy monstera, one person, an intact mug and a blurry shot. Then I opened every photo next to its details and marked each one true, false or unsure.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Photos&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gate decision correct&lt;/td&gt;
&lt;td&gt;16 of 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Obituaries printed&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passed the check on the first draft&lt;/td&gt;
&lt;td&gt;12 of 13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewritten once after the check&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repaired by code after the rewrite&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Details on printed cards&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Really in the photo, checked by eye&lt;/td&gt;
&lt;td&gt;53 true, 4 false, 8 unsure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Causes of death resting on a false detail&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median seconds&lt;/td&gt;
&lt;td&gt;0.8 to look, 2.4 to write and check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Machine: Apple M5 Max, 64 GB, &lt;code&gt;gemma4:e4b&lt;/code&gt; through Ollama 0.35.1. A card takes about 3 seconds. The one rejected draft was the charger's: its third life sentence cited no detail, and the rewrite fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest failure.&lt;/strong&gt; My first look prompt asked for one list of details, each tagged damage, wear or feature. Gemma tagged almost everything as a feature. Burnt toast came back as "dark brown toasted surfaces", so the check found no injury and printed "Natural causes. It was simply no longer needed." Only 6 of 11 dead things had any damage or wear listed, and a split laptop charger became "white wired earbuds".&lt;/p&gt;

&lt;p&gt;So I split the schema into three lists and asked for damage first. On the next run all 11 dead things had an injury. That run had its own bug: the model wrote "no visible damage" as an entry in the damage list, the check accepted it as an injury, and a blurred green glow was buried as Miss Petunia Bloomsworth, who "passed away due to no visible damage." Code now drops entries like that.&lt;/p&gt;

&lt;p&gt;The table shows the check's limit. It proves that each sentence points at a listed detail, not that the detail is real. Two causes of death rested on details I could not find in the photo: a cassette adapter "suffered a missing screw hole" while its real injury, heat warping, went unmentioned, and a folded umbrella died of "torn fabric". That is why the card footer prints the model's list: you can hold the obituary up against the photo.&lt;/p&gt;

&lt;p&gt;How it was tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;48 Go unit test cases: the check, the gate, the repair step, the HTTP routes, and the Ollama client against a fake server (bad JSON, missing model, Ollama down, timeout).&lt;/li&gt;
&lt;li&gt;The live run above, plus two extra passes on three photos. The details barely moved, since the look step runs at temperature 0.2. The names changed each time, except the pumpkins, who were Mr. Josiah Gourdington twice.&lt;/li&gt;
&lt;li&gt;10 Playwright tests against the real server and model: upload, card, sentence-to-detail mapping, PNG download, still-alive notices, phone width, keyboard, missing model, Ollama down, the LAN address, and a full run that fails if any request leaves the machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: Go 1.26 with net/http serving a React, Vite and TypeScript page; Gemma 4 E4B through Ollama; html-to-image for the PNG; Old Standard TT and UnifrakturMaguntia fonts bundled from Fontsource. The Ollama request pattern (structured JSON output, timeouts, plain-language errors) comes from my earlier project &lt;a href="https://dev.to/nazar-boyko/a-local-ai-reads-my-kids-school-papers-so-i-dont-have-to-2knk"&gt;Backpack&lt;/a&gt;. The code here is new.&lt;/p&gt;

&lt;p&gt;Photo credits, all from Wikimedia Commons: &lt;a href="https://commons.wikimedia.org/wiki/File:Old_jack-o-lanters_(54177345952).jpg" rel="noopener noreferrer"&gt;jack-o'-lanterns&lt;/a&gt; by Giulia Forsythe (CC BY 2.0), &lt;a href="https://commons.wikimedia.org/wiki/File:Two_pieces_of_burnt_toast.jpg" rel="noopener noreferrer"&gt;burnt toast&lt;/a&gt; by Sheep purple (CC BY 2.0), &lt;a href="https://commons.wikimedia.org/wiki/File:Homebase_Succulent_Rescue_Attempt_(4526722760).jpg" rel="noopener noreferrer"&gt;succulent&lt;/a&gt; by stephen boisvert (CC BY 2.0), &lt;a href="https://commons.wikimedia.org/wiki/File:Close-up_of_a_burnt_light_bulb_on_the_ground.jpg" rel="noopener noreferrer"&gt;light bulb&lt;/a&gt; by Shixart1985 (CC BY 2.0), and the &lt;a href="https://commons.wikimedia.org/wiki/File:Worn-out_torn_shabby_hole-ridden_grey_canvas_shoes_light_soles_dirty_used_broken_laces_Utg%C3%A5tte_slitte_hullete_fillete_gr%C3%A5_t%C3%B8ysko_med_lette_gr%C3%A5_plasts%C3%A5ler_s%C3%A5ler_brukte_skitne_%C3%B8delagte_skolisser_Norway_2025_IMG_1772.jpg" rel="noopener noreferrer"&gt;shoes on the cover&lt;/a&gt; by Wolfmann (CC BY 4.0).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;These photos show the inside of your home: the kitchen counter, the bathroom shelf, whatever is headed for the trash. With an open-weight model on the laptop they never leave it. No upload, no account, no retention policy. The server holds the photo in memory for one request and never writes it to disk.&lt;/p&gt;

&lt;p&gt;It works with Wi-Fi off. The browser test records every request during a full run, and the count going to any host outside the machine is zero. Each obituary costs nothing.&lt;/p&gt;

&lt;p&gt;Because the model runs on my machine, I could cut its job into two strict steps and check the output in my own code. The schemas, the temperatures and the check live in my repository, not behind an endpoint that can change or disappear, and &lt;code&gt;HERELIES_MODEL&lt;/code&gt; swaps in any other Ollama model.&lt;/p&gt;

&lt;p&gt;The limit is the eyesight of a small model. Gemma called the broken mug a "creamer jug" with a spout it does not have, and 4 of 65 details were wrong. I can see those mistakes because the pipeline shows them to me, and I prefer that to a better answer I cannot inspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The most surprising result was a refusal. In the first run Gemma looked at a rotting succulent, translucent and black at the edges, and decided it was alive: "This plant is still with us. Water it." Once the prompt said that brown, mushy or translucent leaves mean dead, Agnes Petalbrook got her obituary.&lt;/p&gt;

&lt;p&gt;Día de los Muertos is about remembering through stories. A dead charger is not a grandmother, but it deserves a better send-off than the trash can: a name, a cause of death, and someone to survive it. In its case, "a set of mismatched plugs and a seldom-used surge protector."&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 4 runs locally through Ollama, does both the looking and the writing, and code checks its output against what it saw.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>go</category>
    </item>
    <item>
      <title>A Local AI Reads My Kid’s School Papers So I Don’t Have To</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Sat, 24 Oct 2026 20:53:32 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/a-local-ai-reads-my-kids-school-papers-so-i-dont-have-to-2knk</link>
      <guid>https://dev.to/nazar-boyko/a-local-ai-reads-my-kids-school-papers-so-i-dont-have-to-2knk</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every Friday my kindergartner's folder comes home full. Most of it is worksheets and coloring pages, and somewhere in between there is sometimes a slip or a flyer. I have to look through all of it to find the one paper that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Backpack is a small app that reads those papers for you. You photograph each one with your phone, drop the photos on a page in your browser, and Gemma 4 reads them on your own laptop. A few seconds per paper later you have the week: every date in order with one button that puts them in your calendar, every to-do with a checkbox, and a single line for the papers that need nothing from you.&lt;/p&gt;

&lt;p&gt;The parent who used to sort the pile is the one who gets to be lazy now.&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%2Fbwkm53x23mh38wimzalm.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%2Fbwkm53x23mh38wimzalm.png" alt="The week in Backpack: dates with a Download calendar button, then the open to-dos" width="800" height="1674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some things are missing on purpose: no account, no sync, no settings page, no model picker. You get one binary, one page and one folder on your disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The landing page, with screenshots and the install steps: &lt;strong&gt;&lt;a href="https://nazboyko.github.io/backpack/" rel="noopener noreferrer"&gt;https://nazboyko.github.io/backpack/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Papers are read one at a time. One that can't be read says so and offers to try again:&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%2F378z2th6mkqkv2d0fyxl.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%2F378z2th6mkqkv2d0fyxl.png" alt="Cards for a paper being read, one waiting its turn, and one that could not be read with a Read again button" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A date the model had to work out is underlined and marked "check". Click it and it turns into a date field:&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%2Fzvvfqxklyuw0bpisvx0w.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%2Fzvvfqxklyuw0bpisvx0w.png" alt="The list of dates with the reading log's guessed Friday open in a date field" width="800" height="867"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Download calendar (.ics) gives you one file. Apple Calendar took all 14 events from the ten test papers, with their times and the two-day book fair:&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%2Favr1f07m7lqreruba9hq.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%2Favr1f07m7lqreruba9hq.png" alt="Apple Calendar after importing the file Backpack made from ten papers" width="799" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/nazboyko" rel="noopener noreferrer"&gt;
        nazboyko
      &lt;/a&gt; / &lt;a href="https://github.com/nazboyko/backpack" rel="noopener noreferrer"&gt;
        backpack
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Photograph the pile, get the week. Reads school papers with Gemma 4 on your own laptop.
    &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;Backpack&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Photograph the pile, get the week.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/backpack/docs/img/week.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fbackpack%2FHEAD%2Fdocs%2Fimg%2Fweek.png" alt="The week in Backpack: dates with a calendar download, to-dos with checkboxes"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Every week a school backpack comes home with paper: permission slips, picture
day flyers, "sign and return by Friday", early release, pizza night. Backpack
reads photos of those papers with Gemma 4, an open-weight model that runs on
your own laptop through Ollama, and gives you one page with the dates, the
to-dos and the papers you can throw away. Nothing leaves your computer.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Install&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;On a Mac with &lt;a href="https://brew.sh" rel="nofollow noopener noreferrer"&gt;Homebrew&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;brew install go ollama &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew services start ollama
ollama pull gemma4:e4b
go install github.com/nazboyko/backpack/cmd/backpack@latest
&lt;span class="pl-k"&gt;~&lt;/span&gt;/go/bin/backpack&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The last command opens the page in your browser. Next time, run only that one
The model download is about 7 GB and happens once.&lt;/p&gt;
&lt;p&gt;From a clone: &lt;code&gt;make run&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How to use it&lt;/h2&gt;

&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Take a photo of each paper with your phone and send them to the laptop.&lt;/li&gt;
&lt;li&gt;Drop the photos on the…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nazboyko/backpack" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;go ollama &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew services start ollama
ollama pull gemma4:e4b
go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/nazboyko/backpack/cmd/backpack@latest
~/go/bin/backpack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last command opens the page at &lt;code&gt;http://127.0.0.1:7313&lt;/code&gt;. The tests come in four layers: unit tests, an end-to-end test that drives the real server against a fake Ollama, &lt;code&gt;make live-check&lt;/code&gt;, which reads ten invented school papers with the real model and scores the result, and a guard test that fails if the code ever names a host other than this computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The model is gemma4:e4b, the small open-weight Gemma 4, served by Ollama on the same laptop. Backpack sends each photo to Ollama's chat API with &lt;code&gt;format&lt;/code&gt; set to a JSON schema, so the model can only answer in one shape. This is its answer for the field trip slip, trimmed:&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Field Trip to Science Museum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nothing_to_do"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&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="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Field Trip to Riverside Science Museum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-04"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"09:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14:15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&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="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sign and return the slip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"return"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&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="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Send $8 per child"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pay"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="nl"&gt;"due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"date_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read"&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 prompt gives the model today's date and the school year and tells it to turn "Friday" and "next week" into real dates. It spells out the difference between an event (a trip, a day off, a fair) and an action (sign, return, pay, bring). It asks for "guessed" whenever the paper printed only a weekday or the photo was unclear.&lt;/p&gt;

&lt;p&gt;Then the Go side checks everything, because a small model gets things wrong in small ways. Every date has to be a real day. "Friday" or "Nov. 4" gets resolved and marked as a guess. A date more than six months in the past moves to the nearest year, because in my first run the model filed a November pizza night under 2024. "00:00" means no time, "5-7 PM" in one field becomes a range, and two all-day "Book Fair" events on consecutive days become one.&lt;/p&gt;

&lt;p&gt;With the prompt from my plan, before any tuning, the model found 11 of 13 dates and 6 of 10 to-dos on the ten test papers. It also added 13 lines that were not on the papers, four of them events on a class newsletter, all dated today. Two rounds of prompt changes plus the checks above brought it to 13 of 13 dates, 8 of 10 to-dos and 4 extra lines, and the newsletter now comes back as "nothing to do". It still misses "pack a sack lunch" on the field trip slip and "send a little money" on the bake sale flyer.&lt;/p&gt;

&lt;p&gt;The larger gemma4:26b found all 10 to-dos on the same papers, at 3.6 seconds a paper instead of about 2, with an 18 GB download instead of 7 GB. e4b stays the default; 26b is one environment variable away.&lt;/p&gt;

&lt;p&gt;Everything else is one Go binary with the page embedded: plain HTML, CSS and about 640 lines of JavaScript, no framework and no build step, so a parent installs one thing. It listens on 127.0.0.1 only, rejects requests for any other host name, and rejects changes sent by other websites open in the same browser. Every word the model writes reaches the page as text, never as HTML.&lt;/p&gt;

&lt;p&gt;Where it is weak: handwriting and faint photocopies. My test papers are printed. One has a handwritten-style note ("we need 2 dozen cookies") and the model got it, but a teacher's real handwriting on a faded copy is harder. That is what the "check" marks are for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Think about what a school paper can carry: a child's name, sometimes in the child's own handwriting, the school, the teacher and the room number. A form asks for a home address and a parent's phone number. Now and then there is a note about an allergy. I don't want any of that on someone else's server, and with an open-weight model it never goes there. Backpack talks to 127.0.0.1 and nothing else, and a test in the repo fails the build if the code ever names another host.&lt;/p&gt;

&lt;p&gt;Open weights also mean there is nothing to pay next month and nothing to sign up for. There's no API key to paste and no usage limit, and nobody can change the terms under me. It works on a plane. When a better open model comes out, I change one variable: &lt;code&gt;BACKPACK_MODEL=gemma4:26b&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A large closed model behind an API would probably read a crumpled handwritten note better than a 7 GB model on a laptop does today. I chose the laptop anyway. For my kindergartner's paperwork I'll trade some accuracy for keeping it in the house, and I built the page so that I see every guess before I trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;On an Apple M5 Max with 64 GB, with the model not even loaded, I dropped all ten test papers at once. 24 seconds later every one was read: the first took 5 seconds while the model loaded, the rest about 2 each. Backpack found all 13 dates and 8 of the 10 to-dos.&lt;/p&gt;

&lt;p&gt;Then I tried this week's real folder. I photographed 11 papers, dropped them all at once, and 20 seconds later every card was read. All 11 came back "Nothing to do", and all 11 were right: a week of worksheets and coloring pages, with no notes, no slips and no dates. The one thing I did have to do this week, return a library book, was not written on any paper, so Backpack could not know about it. It reads paper. It doesn't know what nobody wrote down. One cosmetic miss: a summary stopped mid-sentence ("A coloring and pasting activity for a"). Nothing was invented, and I fixed nothing by hand.&lt;/p&gt;

&lt;p&gt;I don't sort the pile anymore. I drop it, look at the "check" marks, and press one button for the calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 reads every paper. Without it, Backpack is a drop zone and nothing else.&lt;/p&gt;

&lt;p&gt;The repo is open under MIT, with the test papers, the scores and the prompt in it.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>go</category>
    </item>
    <item>
      <title>I Let AI Translate My YouTube Titles. Then I Asked What It Actually Said.</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Sun, 18 Oct 2026 18:44:12 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/i-let-ai-translate-my-youtube-titles-then-i-asked-what-it-actually-said-4ng</link>
      <guid>https://dev.to/nazar-boyko/i-let-ai-translate-my-youtube-titles-then-i-asked-what-it-actually-said-4ng</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;If your videos get views from Brazil or Japan, YouTube already shows those viewers your title in Portuguese or Japanese. A machine wrote it, and you have never read it.&lt;/p&gt;

&lt;p&gt;This month I started two faceless channels: rain and night sounds on one, slow daytime scenes on the other. Studio lets me replace YouTube's machine version with my own, but that means two fields per language per video. Fifty videos in twelve languages is 1,200 fields.&lt;/p&gt;

&lt;p&gt;Channel Tongues is a Chrome side panel that opens next to YouTube Studio, plus a small helper on my laptop. I press Translate, and Gemma 4, running in Ollama on the same laptop, writes the title and description in the twelve languages with the biggest YouTube audiences. Under every title the panel shows what the translation says in English. I untick anything I don't trust, press Publish, and the helper adds the rest to the video through the official YouTube Data API, after taking a backup.&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%2Fjfa49djnbr3nvkpygqpr.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%2Fjfa49djnbr3nvkpygqpr.png" alt="Look it over: each language card shows the translated title and a " width="360" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;People already pay for tools that do this. I wanted one that runs on my own machine and never holds my channel's keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/3xA-Rxhp31k" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Screenshots and install steps: &lt;a href="https://nazboyko.github.io/channel-tongues/" rel="noopener noreferrer"&gt;nazboyko.github.io/channel-tongues&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/nazboyko" rel="noopener noreferrer"&gt;
        nazboyko
      &lt;/a&gt; / &lt;a href="https://github.com/nazboyko/channel-tongues" rel="noopener noreferrer"&gt;
        channel-tongues
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Translate your video titles and descriptions with an open model on your own machine, then add them to the video.
    &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;Channel Tongues&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Your channel's titles and descriptions in every language, translated on your own laptop.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/channel-tongues/docs/screenshots/8-review-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fchannel-tongues%2FHEAD%2Fdocs%2Fscreenshots%2F8-review-dark.png" width="360" alt="The Channel Tongues side panel showing translated titles and descriptions, ready to publish"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;YouTube already shows viewers abroad a machine translation of your titles, and you never get to read it. Channel Tongues opens next to YouTube Studio as a Chrome side panel; you press Translate, and Gemma 4 running on your computer through Ollama writes the title and description in the twelve languages with the biggest YouTube audiences. You read them over, press Publish, and the helper adds them to the video through the official YouTube Data API after taking a backup.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=3xA-Rxhp31k" rel="nofollow noopener noreferrer"&gt;Demo video&lt;/a&gt; · &lt;a href="https://nazboyko.github.io/channel-tongues/" rel="nofollow noopener noreferrer"&gt;Landing page&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Install&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;You need a Mac or Linux machine with Chrome, Go 1.26 and Node 26.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Install Ollama from &lt;a href="https://ollama.com/download" rel="nofollow noopener noreferrer"&gt;ollama.com/download&lt;/a&gt; and open it.&lt;/li&gt;
&lt;li&gt;Get the code and build it
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;git clone https://github.com/nazboyko/channel-tongues &amp;amp;&amp;amp; cd channel-tongues &amp;amp;&amp;amp; make build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;Run the setup. It downloads the model, walks you through your own Google…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nazboyko/channel-tongues" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;A Go helper and a React side panel, MIT license. &lt;code&gt;make build&lt;/code&gt;, then &lt;code&gt;bin/tongues setup&lt;/code&gt; walks you through the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The shape.&lt;/strong&gt; The side panel (React, Manifest V3) reads the video id from the tab address and talks to a Go helper on 127.0.0.1. It never changes Studio's page, and reads from it only in the no-sign-in mode, after you allow it. The helper runs the model, keeps the sign-in file and writes backups, and Chrome starts it when you open the panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protect, translate, check.&lt;/strong&gt; Before Gemma sees anything, the helper swaps links, timestamps, hashtags and keep words (my channel names) for placeholders like &lt;code&gt;⟦1⟧&lt;/code&gt;. Then the checks run: every placeholder back in place, lines and blank lines kept, the title within 100 characters, the right script, no link or keyword the original lacks. A failed check retries twice with the exact problem. After that the card says &lt;strong&gt;Check this one&lt;/strong&gt; with the reason, and it goes out only if I tick it myself.&lt;/p&gt;

&lt;p&gt;On my first real publish, the Spanish title came back as "cabina nocturna 30s · escenas nocturnas tranquilas y música suave para dormir profundamente", with the description's first sentence glued on. Now the checks reject a title that grows a new part. With the smaller &lt;code&gt;gemma4:e4b&lt;/code&gt;, the Korean title added "ASMR" on all three tries, and the panel held it back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The read-back line.&lt;/strong&gt; A placeholder check can't tell me what a Thai title means. So the same model translates each translation back into English, and the panel shows it under the title. The panel marks words my original doesn't have, and a marked title makes the card Check this one. On my sample videos it caught Turkish turning the cabin into a "mountain" cabin, and Vietnamese and Thai adding "help sleep".&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%2Fiq4fqgltj08ws1e5xozx.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%2Fiq4fqgltj08ws1e5xozx.png" alt="A Check this one card: the Turkish read-back marks " title="" width="360" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On my first real panel run, 10 of 15 cards came back to check, and 9 of them blamed "created": my description said "made", and nearly every read-back said "created". Now a description word that at least half the read-backs share counts as the read-back's own wording, and only title words untick a card. The same video went from 10 cards to check to 0, and the Turkish "mountain" still gets caught. The read-back is a second opinion from the same model, and still the first time I can see what my Thai title says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merge, never replace.&lt;/strong&gt; YouTube deletes any language you leave out of an update. So the helper reads the video first, starts from what is there and sends the complete set: it adds languages the video lacks, updates the ones it wrote last time, and keeps everything else. It never sends the title, description or tags. Before every write it saves a backup of the video's translations, re-reads the video and refuses if anything changed. After the write it reads each language back. Undo in the panel, or &lt;code&gt;tongues restore&lt;/code&gt; on the command line, puts the backup back.&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%2F2hw8kygqtajcdsxo7evi.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%2F2hw8kygqtajcdsxo7evi.png" alt="Published: five languages added with a check mark, five marked " width="360" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a run costs.&lt;/strong&gt; &lt;code&gt;tongues translate --measure&lt;/code&gt; prints the seconds, the tokens Ollama counted and, with &lt;a href="https://github.com/vladkens/macmon" rel="noopener noreferrer"&gt;macmon&lt;/a&gt; installed, what the whole machine drew. Twelve languages with &lt;code&gt;gemma4:26b&lt;/code&gt; on an M5 Max with 64 GB, model already loaded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Short sample&lt;/th&gt;
&lt;th&gt;Real video&lt;/th&gt;
&lt;th&gt;Real video&lt;/th&gt;
&lt;th&gt;Test video&lt;/th&gt;
&lt;th&gt;Long sample&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Title / description, characters&lt;/td&gt;
&lt;td&gt;57 / 273&lt;/td&gt;
&lt;td&gt;96 / 691&lt;/td&gt;
&lt;td&gt;99 / 727&lt;/td&gt;
&lt;td&gt;93 / 1,042&lt;/td&gt;
&lt;td&gt;94 / 1,979&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start to finish&lt;/td&gt;
&lt;td&gt;22-25 s&lt;/td&gt;
&lt;td&gt;54 s&lt;/td&gt;
&lt;td&gt;56 s&lt;/td&gt;
&lt;td&gt;59-62 s&lt;/td&gt;
&lt;td&gt;157-162 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retries&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4-5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output tokens, translation and read-back&lt;/td&gt;
&lt;td&gt;2,484-2,518&lt;/td&gt;
&lt;td&gt;5,819&lt;/td&gt;
&lt;td&gt;6,566&lt;/td&gt;
&lt;td&gt;6,788-6,796&lt;/td&gt;
&lt;td&gt;17,710-18,315&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Energy, whole machine (macmon)&lt;/td&gt;
&lt;td&gt;0.59-0.62 Wh&lt;/td&gt;
&lt;td&gt;1.34 Wh&lt;/td&gt;
&lt;td&gt;1.50 Wh&lt;/td&gt;
&lt;td&gt;1.33-1.40 Wh&lt;/td&gt;
&lt;td&gt;4.00-4.03 Wh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ready / to check / failed&lt;/td&gt;
&lt;td&gt;10 / 2 / 0&lt;/td&gt;
&lt;td&gt;12 / 0 / 0&lt;/td&gt;
&lt;td&gt;11 / 1 / 0&lt;/td&gt;
&lt;td&gt;12 / 0 / 0&lt;/td&gt;
&lt;td&gt;12 / 0 / 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real descriptions on my channels run 293 to 1,042 characters, so twelve languages take a minute or less and at most 1.5 Wh. On the long sample I wrote for testing, Gemma used to get stuck in Hindi and repeat one word, पढ़ते ("reading"), until the answer limit. A retry with a repeat penalty now comes through, and the long sample dropped from 212-219 seconds to 157-162.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I cut.&lt;/strong&gt; Translating three languages at once. With &lt;code&gt;gemma4:e4b&lt;/code&gt;, translation only, twelve languages took 11.9 and 12.5 seconds one at a time and 12.4 and 14.0 three at once, so one at a time stayed. A model picker and a settings page: the helper uses the best Gemma 4 you have, and your keep words live in one fold at the bottom of the panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;The person most afraid of a tool like this is a channel owner. An open tool that runs on your machine answers that fear with things you can check.&lt;/p&gt;

&lt;p&gt;Google's consent screen for the YouTube scope says the app can "see, edit and permanently delete" your videos. Google offers no narrower scope that only writes translations, so any tool that publishes them has to ask for that much. Channel Tongues keeps the dangerous part small and visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Google project is your own, the sign-in file stays on your disk, and Sign out deletes it and tells Google.&lt;/li&gt;
&lt;li&gt;The code calls four API methods: three reads (your channel, your uploads list, your videos) and one write, &lt;code&gt;videos.update&lt;/code&gt;, which sends only the translations. The one exception is a separate command that sets a video's language, and you run it by hand.&lt;/li&gt;
&lt;li&gt;Every write starts with a backup and ends with a read-back, and Undo takes one click.&lt;/li&gt;
&lt;li&gt;You can skip the sign-in. "I'll paste it myself" translates on your machine, and you copy the results or save them to a folder. No Google account appears on that path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running locally gives you the rest. A back catalog of a few hundred videos costs nothing per video, so you can localize all of it. Your channel and series names stay as written; YouTube's machine translation has no way to know they are names. And a better open model next month is one &lt;code&gt;ollama pull&lt;/code&gt; and one setting away.&lt;/p&gt;

&lt;p&gt;One cloud extension in this space states in its Chrome Web Store listing that it collects "authentication information", "web history" and "user activity". That may suit many people. For a channel owner who worries about their account, a local model and a local sign-in file decide whether they try a tool at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limits.&lt;/strong&gt; YouTube can't translate tags. The free API quota covers about 190 publishes a day. A Google project in Testing status signs you out every seven days. The read-back shares the model's blind spots and raises some false alarms, such as Japanese "Pleasant" for "Cozy". A translation can also pass every check and still get the meaning wrong: with &lt;code&gt;gemma4:e4b&lt;/code&gt;, "Light Waves" at a harbor became "waves of light". Read before you publish; the panel shows the original next to every card.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;I am the first user, on my own two channels, The Slow Night Hours and The Living Postcard.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Living Postcard's three public videos had no translations. &lt;code&gt;bin/tongues channel localize --langs used --apply&lt;/code&gt; added the 11 languages I last picked to each in 1 minute 54 seconds, with a backup before each write and none held back. By hand in Studio, that is 66 fields.&lt;/li&gt;
&lt;li&gt;On a private test video on The Slow Night Hours, before the read-back line existed, the panel translated twelve languages in 10.4 seconds, published all twelve in one click, and Undo put the video back as it was.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma: Gemma 4 writes every translation and every read-back through Ollama on the creator's own laptop, as &lt;code&gt;gemma4:26b&lt;/code&gt;, or &lt;code&gt;gemma4:e4b&lt;/code&gt; on smaller machines.&lt;/p&gt;




&lt;p&gt;Credits: &lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma 4&lt;/a&gt; by Google (&lt;a href="https://ai.google.dev/gemma/terms" rel="noopener noreferrer"&gt;Gemma Terms of Use&lt;/a&gt;) through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; (MIT); the Google API client for Go, golang.org/x/oauth2 and golang.org/x/text (BSD-3-Clause); modernc.org/sqlite (BSD-3-Clause); React, Vite and Vitest (MIT); the Inter typeface (SIL Open Font License 1.1); &lt;a href="https://github.com/vladkens/macmon" rel="noopener noreferrer"&gt;macmon&lt;/a&gt; for the power numbers (MIT). Audience figures from DataReportal's Digital 2025. Channel Tongues is MIT-licensed and not affiliated with YouTube or Google.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Models Read the Release Notes. I Measured the Runtime.</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Sat, 10 Oct 2026 14:13:00 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/the-models-read-the-release-notes-i-measured-the-runtime-gei</link>
      <guid>https://dev.to/nazar-boyko/the-models-read-the-release-notes-i-measured-the-runtime-gei</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Benchmarked
&lt;/h2&gt;

&lt;p&gt;I've written PHP for most of my career and Go for the last few years, and in code reviews I repeat the same memory rules everyone repeats. A goroutine is 2 KB, don't worry about it. An &lt;code&gt;int64&lt;/code&gt; to &lt;code&gt;int64&lt;/code&gt; map entry is 16 bytes. &lt;code&gt;unset()&lt;/code&gt; gives the memory back. Use &lt;code&gt;map[K]struct{}&lt;/code&gt; for sets, because &lt;code&gt;struct{}&lt;/code&gt; is free. I never checked any of them. Why would I? They've always been true.&lt;/p&gt;

&lt;p&gt;Except some of them aren't anymore. PHP 8.2 stopped storing keys in packed arrays (&lt;a href="https://github.com/php/php-src/pull/7491" rel="noopener noreferrer"&gt;php-src #7491&lt;/a&gt;). Go 1.24 threw out its old map implementation for &lt;a href="https://go.dev/doc/go1.24#runtime" rel="noopener noreferrer"&gt;Swiss Tables&lt;/a&gt;. Go 1.19 started &lt;a href="https://go.dev/doc/go1.19#runtime" rel="noopener noreferrer"&gt;picking the initial goroutine stack size&lt;/a&gt; from what it had seen so far. The rules stayed in our heads; the runtimes moved on. So I got curious: when a model answers a memory question, what is it actually remembering? The rule, the runtime, or the specific version?&lt;/p&gt;

&lt;p&gt;Let's play. Guess before you scroll. Both maps hold one million keys:&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="n"&gt;set&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{}{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How does the heap held by &lt;code&gt;set&lt;/code&gt; compare with &lt;code&gt;flags&lt;/code&gt;?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A. set is about 10% smaller&lt;/li&gt;
&lt;li&gt;B. set is about half the size of flags&lt;/li&gt;
&lt;li&gt;C. About the same&lt;/li&gt;
&lt;li&gt;D. It depends on the Go version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick one. The answer, with the measured number, is waiting in Findings. If you enjoy this kind of thing, all seventeen cases are on &lt;a href="https://nazboyko.github.io/memory-trap-bench/" rel="noopener noreferrer"&gt;this page&lt;/a&gt;: you pick an option, then you see what the runtime actually did.&lt;/p&gt;

&lt;p&gt;Seventeen cases in total: seven about PHP arrays, five about Go maps, five about goroutine stacks. Three of them are controls, cases where the old rule is still right. Without those, a model could score well just by betting against every rule, and I didn't want to reward cynicism.&lt;/p&gt;

&lt;p&gt;One thing I was strict about: not a single "correct answer" came from a blog post, including mine. A small harness builds each structure and reads &lt;code&gt;memory_get_usage()&lt;/code&gt; in PHP or &lt;code&gt;runtime.MemStats&lt;/code&gt; in Go. A GitHub Actions matrix runs it three times on PHP 8.1 through 8.4 and Go 1.23 through 1.26. If you don't trust my numbers, and you shouldn't have to, one command reruns everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sh measure/run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each case went to the models three times, as three separate Kaggle tasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cold&lt;/strong&gt;: just the code and the question,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;versioned&lt;/strong&gt;: the same, plus the runtime version,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;evidence&lt;/strong&gt;: the same, plus one measured number from a neighbouring case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Models answer in JSON: an option, an estimate in MB or KB, which mechanism they think explains it, and a confidence from 0 to 100. Plain code does the scoring, no LLM judge anywhere. A third of the point for the right option, a third for an estimate within 0.67x to 1.5x of the measured value, a third for the right mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;Twelve models from Kaggle's list. I picked them along three lines: a small and a flagship model from each vendor, a newer and an older generation of the same vendor (did newer training data pick up Go 1.24 maps?), and a few open-weight models.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt;, &lt;code&gt;gemini-3.8-flash&lt;/code&gt;, &lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt;, &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, &lt;code&gt;claude-haiku-5-5&lt;/code&gt;, &lt;code&gt;claude-opus-5-5-default&lt;/code&gt;, &lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt;, &lt;code&gt;gpt-5.4-nano-2026-03-17&lt;/code&gt;, &lt;code&gt;gpt-6.1-sol&lt;/code&gt;, &lt;code&gt;gpt-oss-120b&lt;/code&gt;, &lt;code&gt;gemma-4-31b-it&lt;/code&gt;, &lt;code&gt;qwen3-coder-480b-a35b-instruct&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing returned a 404 and I swapped nothing out. Eleven of the twelve have a complete run on all three tasks. The one that doesn't is &lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt;: 13 cold questions cost $0.89, one of its replies ran to 22,064 output tokens, and Kaggle gives my account $10 of model calls a day. Its partial replies are in the repository, but it's in none of the tables below.&lt;/p&gt;

&lt;p&gt;There's also a thirteenth model in the tables, and I didn't invite it. When you create a task, Kaggle runs it once on &lt;code&gt;gemini-3.7-flash&lt;/code&gt;. Those runs are as real as mine, so I kept them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, the answer to the game.&lt;/strong&gt; On Go 1.23 the &lt;code&gt;struct{}&lt;/code&gt; set is about 10% smaller than the &lt;code&gt;bool&lt;/code&gt; map (ratio 0.91). On Go 1.24 and later they're the same size (ratio 1.00). So if nobody told you the version, the honest answer is D. Swiss-table maps keep key and value together in one aligned slot, and an empty value saves you nothing. When I put the version in the prompt, exactly 2 of 12 models followed the change: &lt;code&gt;gpt-6.1-sol&lt;/code&gt; and &lt;code&gt;claude-opus-5-5-default&lt;/code&gt;. Four others did change their answer for Go 1.24, in the wrong direction, to "half the size".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two best models lost the same question, and it was a control.&lt;/strong&gt; Here's the setup. I start 100,000 goroutines that each hold an 8 KB array, run a GC, then start 100,000 idle goroutines, and ask how much stack each idle one takes. The old rule says 2 KB. The Go 1.19 release notes say the runtime sizes new stacks from the average it has seen, which points at 16 KB. I measured 2.0 KB, on Go 1.23 through 1.26. Then I changed one thing: I let the first batch exit before starting the idle batch. Now each idle goroutine takes 16.0 KB.&lt;/p&gt;

&lt;p&gt;Both numbers are right, and the runtime source explains why. &lt;a href="https://go.dev/src/runtime/proc.go#L5352" rel="noopener noreferrer"&gt;&lt;code&gt;newproc1&lt;/code&gt;&lt;/a&gt; gives a brand-new goroutine a stack of &lt;code&gt;stackMin&lt;/code&gt;, which is 2 KB. &lt;a href="https://go.dev/src/runtime/proc.go#L5558" rel="noopener noreferrer"&gt;&lt;code&gt;gfget&lt;/code&gt;&lt;/a&gt; hands out the descriptor of a finished goroutine again, and that one gets &lt;code&gt;startingStackSize&lt;/code&gt;, the adaptive size. The links point at the current release, Go 1.27; the four versions I measured have the same two lines.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gpt-6.1-sol&lt;/code&gt; got 56 of 59 questions right. The three it missed are this control, once per task, at a stated confidence of 98 or 99. &lt;code&gt;claude-opus-5-5-default&lt;/code&gt; missed it three times too. Both basically recited the release note back to me, with arithmetic. Across 36 runs, a model kept the control and lost its twin 19 times, did the reverse 14 times, and got both right twice. Both of those were &lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the part that changed how I'll use these models for runtime questions. A strong model knows the changelog better than I do. It also applies the changelog in places where the code doesn't.&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%2Foi4d8diflx2iapu6kyup.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%2Foi4d8diflx2iapu6kyup.png" alt="Control case: gpt-6.1-sol said 16 KB at confidence 98, 99 and 98, claude-opus-5-5-default said 16 KB at confidence 72, 80 and 72, and the harness measured 2.0 KB on Go 1.23, 1.24, 1.25 and 1.26" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leaderboard.&lt;/strong&gt; Task score, one complete run per model and task. The row without a slug is me, cold condition only.&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%2Fg7d08htdrgypn3o1odxy.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%2Fg7d08htdrgypn3o1odxy.png" alt="Mean task score per model: gpt-6.1-sol 0.95, claude-opus-5-5-default 0.94, gemini-3.8-flash 0.83, claude-haiku-5-5 0.81, gemini-3.7-flash 0.80, gemma-4-31b-it 0.70, gemini-2.5-flash 0.67, gpt-oss-120b 0.55, claude-haiku-4-5-20251001 0.52, gemini-3.5-flash-lite 0.50, qwen3-coder-480b-a35b-instruct 0.46, gpt-5.4-nano-2026-03-17 0.39; the author scored 0.67 on the cold task" width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;cold&lt;/th&gt;
&lt;th&gt;versioned&lt;/th&gt;
&lt;th&gt;evidence&lt;/th&gt;
&lt;th&gt;mean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-6.1-sol&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-opus-5-5-default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.8-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-haiku-5-5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.78&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.7-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;0.78&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemma-4-31b-it&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;td&gt;0.71&lt;/td&gt;
&lt;td&gt;0.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.67&lt;/td&gt;
&lt;td&gt;0.68&lt;/td&gt;
&lt;td&gt;0.65&lt;/td&gt;
&lt;td&gt;0.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;author, cold only&lt;/td&gt;
&lt;td&gt;0.67&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-oss-120b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.53&lt;/td&gt;
&lt;td&gt;0.57&lt;/td&gt;
&lt;td&gt;0.56&lt;/td&gt;
&lt;td&gt;0.55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.57&lt;/td&gt;
&lt;td&gt;0.51&lt;/td&gt;
&lt;td&gt;0.48&lt;/td&gt;
&lt;td&gt;0.52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.51&lt;/td&gt;
&lt;td&gt;0.49&lt;/td&gt;
&lt;td&gt;0.49&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen3-coder-480b-a35b-instruct&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.41&lt;/td&gt;
&lt;td&gt;0.44&lt;/td&gt;
&lt;td&gt;0.52&lt;/td&gt;
&lt;td&gt;0.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gpt-5.4-nano-2026-03-17&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.37&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;0.44&lt;/td&gt;
&lt;td&gt;0.39&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Within a family, the newer generation wins by a lot: &lt;code&gt;claude-haiku-5-5&lt;/code&gt; at 0.81 against &lt;code&gt;claude-haiku-4-5-20251001&lt;/code&gt; at 0.52, &lt;code&gt;gemini-3.8-flash&lt;/code&gt; at 0.83 against &lt;code&gt;gemini-2.5-flash&lt;/code&gt; at 0.67. The older Gemini also burned more than twice the output tokens per question (5,445 against 2,235) to get the lower score. Not one reply failed to parse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version sensitivity.&lt;/strong&gt; Four cases have a measured answer that flips with the version: three between PHP 8.1 and 8.3, one between Go 1.23 and 1.24. I asked each of those twice, once per version. &lt;code&gt;gpt-6.1-sol&lt;/code&gt; and &lt;code&gt;claude-opus-5-5-default&lt;/code&gt; got all four pairs right. &lt;code&gt;gemini-3.8-flash&lt;/code&gt; and &lt;code&gt;claude-haiku-5-5&lt;/code&gt; got three, &lt;code&gt;gemini-3.7-flash&lt;/code&gt; two, &lt;code&gt;gpt-oss-120b&lt;/code&gt; one. Six models got none. All six said "2.5x" for the reversed array on PHP 8.1 and again on PHP 8.3, where I measured 1.25x and 2.50x. The Swiss-table case was the hardest (2 of 12 models), the SplFixedArray case the easiest (6 of 12).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"It depends" is a rare answer.&lt;/strong&gt; With no version in the prompt, the right answer to those four cases is "it depends on the version". Models picked it in 13 of 48 replies. Most just answered for the newest runtime and moved on. My favourite: &lt;code&gt;claude-opus-5-5-default&lt;/code&gt; wrote "Before PHP 8.2 the ratio was about 1.25x" in its reasoning, and then chose the PHP 8.2 option anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confidence.&lt;/strong&gt; The stated number told me very little. Seven of the twelve models put 80 or more on every single wrong answer they gave. &lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt; and &lt;code&gt;qwen3-coder-480b-a35b-instruct&lt;/code&gt; did it 32 times each, &lt;code&gt;gemini-2.5-flash&lt;/code&gt; 22 times, &lt;code&gt;gemma-4-31b-it&lt;/code&gt; 19. Only two models dropped by more than five points when they were wrong: &lt;code&gt;claude-opus-5-5-default&lt;/code&gt; from 77.5 to 70.2, and &lt;code&gt;claude-haiku-5-5&lt;/code&gt; from 70.7 to 61.6. Haiku 5.5 put 80 or more on none of its 17 wrong answers, which is more self-awareness than I expected from a small model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right answer, wrong reason.&lt;/strong&gt; &lt;code&gt;qwen3-coder-480b-a35b-instruct&lt;/code&gt; picked the measured answer 27 times and explained 11 of those with a wrong mechanism. For &lt;code&gt;gpt-5.4-nano-2026-03-17&lt;/code&gt; it was 6 of 16. For the top two it was 0. If I had scored on the answer letter alone, none of this would have shown up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evidence barely helped.&lt;/strong&gt; Handing a model one measured number from a neighbouring case moved almost nothing. From cold to evidence, the twelve models gained 14 right answers and lost 14.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask the same thing twice.&lt;/strong&gt; Quota errors cut my first pass to pieces, so most models ended up answering many prompts twice. Across 501 pairs of replies to the same prompt, the answer letter matched 82% of the time. &lt;code&gt;gpt-6.1-sol&lt;/code&gt; (19 of 19) and &lt;code&gt;claude-opus-5-5-default&lt;/code&gt; (25 of 25) never changed their minds. &lt;code&gt;gpt-5.4-nano-2026-03-17&lt;/code&gt; and &lt;code&gt;gemini-2.5-flash&lt;/code&gt; matched only 65% of the time. With one run per model, a gap of a few points on this leaderboard can be noise, and you should read it that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which rules are dead, and which refuse to die.&lt;/strong&gt; Not one model chose the folklore explanation for &lt;code&gt;unset()&lt;/code&gt; freeing memory, for the 16-byte map entry, or for &lt;code&gt;delete&lt;/code&gt; giving a map's memory back. Those rules are gone from the training data, it seems. The rules that were once true are the ones that live on. "SplFixedArray saves memory" was the chosen mechanism in 22 of 60 replies and "&lt;code&gt;struct{}&lt;/code&gt; is free" in 21 of 60. I measured SplFixedArray at about half the memory of a plain list on PHP 8.1 (ratio 0.48) and about the same on PHP 8.3 (ratio 0.95). That rule held right up to PHP 8.2 and then quietly stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In their own words.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gpt-6.1-sol&lt;/code&gt; on the control, confidence 98, measured 2.0 KB:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Since Go 1.19, adaptive stack sizing uses the average stack space scanned during the last GC, adds a stack guard, and rounds up to a power of two. The first batch dominates that average with live frames exceeding 8 KB, so the second batch starts with approximately 16 KB stacks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;qwen3-coder-480b-a35b-instruct&lt;/code&gt; on the game from the top, measured ratio 0.91 on Go 1.23 and 1.00 on Go 1.24:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;struct{}{} takes zero bytes, so set only stores keys while flags stores both keys and bool values. Each bool is 1 byte, so flags uses roughly twice the memory for values.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;gpt-5.4-nano-2026-03-17&lt;/code&gt; on 100,000 three-property objects against 100,000 three-key arrays, where the arrays measured 3.12 times the memory on PHP 8.3:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In PHP, each $objects element is an object with overhead (zval + object header/handlers/property metadata), while $arrays elements are arrays with overhead too (zval + HashTable + buckets). For 3 fields, these overheads are of the same order, so memory_get_usage() is typically close.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A few more things the harness measured.&lt;/strong&gt; All of these held on every version I ran:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unset()&lt;/code&gt; on every second element of a one-million-element list leaves &lt;code&gt;memory_get_usage()&lt;/code&gt; exactly where it was (ratio 1.00 on PHP 8.1 and 8.3).&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;int64&lt;/code&gt; to &lt;code&gt;int64&lt;/code&gt; map holds about 38.1 bytes per entry on Go 1.24. The rule says 16.&lt;/li&gt;
&lt;li&gt;Deleting every key and &lt;code&gt;clear(m)&lt;/code&gt; both keep the full map after a GC (36.38 MB on Go 1.24).&lt;/li&gt;
&lt;li&gt;A goroutine holding an 8 KB local array takes 16.0 KB of stack. "2 plus 8" would suggest 10.&lt;/li&gt;
&lt;li&gt;One million two-element arrays &lt;code&gt;[$i, $i]&lt;/code&gt; take 222.0 MB on PHP 8.3 and 390.59 MB on PHP 8.1. A pair of integers is 32 bytes, in theory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;My own score.&lt;/strong&gt; 🤭 Before I looked at a single measurement, I answered the cold questions myself. I scored 0.67 on the same scale as the models: 10 of 17 answer letters, 14 of 17 mechanisms, 10 of 17 estimates inside the tolerance. My mean confidence was 80.5 when I was right and 70.0 when I was wrong. The cases that sting are P1, P4, P8 and G3. On all four the measured answer was "it depends on the version", and I did exactly what most of the models did: answered for the version I use, got the mechanism right on three of them, and never once said "it depends".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The quota, for whoever tries this next.&lt;/strong&gt; Kaggle's model proxy reserves the worst-case cost of every call against a $10 daily quota: the model's maximum output at its output price, which is $2.56 for a single &lt;code&gt;claude-opus-5-5-default&lt;/code&gt; call. I started twelve models at once and 160 questions came back refused. The fix was &lt;code&gt;max_tokens&lt;/code&gt; on every call and no more than four models at a time. All 71 run files together cost $7.84.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd measure next.&lt;/strong&gt; Laravel collections on top of PHP arrays. The same harness on Alpine with musl, where the allocator is different. Goroutine stacks under real request handlers instead of synthetic frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limits, so nobody has to dig for them.&lt;/strong&gt; Seventeen cases and one scored run per model and task; the repeated replies above come from a partial first pass. One runner type per version (GitHub's ubuntu-24.04). Replies that never parse score zero and are counted; there were none. The Kaggle Benchmarks library sends no temperature to the model proxy, so a rerun can differ. Every call carries &lt;code&gt;max_tokens=32768&lt;/code&gt;; the longest reply among the scored runs used 22,329 tokens. &lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt; has no complete run. The human baseline is one person, and he's biased.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Benchmark and leaderboard: &lt;a href="https://www.kaggle.com/benchmarks/nb9300/memorytrap-bench-php-arrays-go-maps/leaderboard" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/nb9300/memorytrap-bench-php-arrays-go-maps/leaderboard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tasks: &lt;a href="https://www.kaggle.com/benchmarks/tasks/nb9300/memorytrap-cold" rel="noopener noreferrer"&gt;memorytrap-cold&lt;/a&gt;, &lt;a href="https://www.kaggle.com/benchmarks/tasks/nb9300/memorytrap-versioned" rel="noopener noreferrer"&gt;memorytrap-versioned&lt;/a&gt;, &lt;a href="https://www.kaggle.com/benchmarks/tasks/nb9300/memorytrap-evidence" rel="noopener noreferrer"&gt;memorytrap-evidence&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Guess-first page with every case: &lt;a href="https://nazboyko.github.io/memory-trap-bench/" rel="noopener noreferrer"&gt;https://nazboyko.github.io/memory-trap-bench/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code, measurements, raw model replies and the full report: &lt;a href="https://github.com/nazboyko/memory-trap-bench" rel="noopener noreferrer"&gt;https://github.com/nazboyko/memory-trap-bench&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Rerun the measurements: &lt;code&gt;sh measure/run.sh&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&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%2F0enaei3bhuf69oh8fymd.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%2F0enaei3bhuf69oh8fymd.png" alt="The MemoryTrap Bench leaderboard on Kaggle: 3 tasks, 12 models; the first four columns show GPT-6.1 Sol 0.95, Claude Opus 5.5 0.94, Gemini 3.8 Flash 0.83 and Claude Haiku 5.5 0.81" width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first four of twelve columns; the full board is behind the leaderboard link above.&lt;/p&gt;

&lt;p&gt;Credits: the Kaggle Benchmarks library and platform ran the models; &lt;a href="https://github.com/shivammathur/setup-php" rel="noopener noreferrer"&gt;setup-php&lt;/a&gt; and &lt;a href="https://github.com/actions/setup-go" rel="noopener noreferrer"&gt;setup-go&lt;/a&gt; gave me eight runtime versions in CI; the PHP and Go release notes, the pull request and the Go runtime source linked above explain the changes the harness measured.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Tabs to Trails: My Unread Tabs Became Walks, Made on My Own Laptop</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Fri, 09 Oct 2026 18:04:03 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/tabs-to-trails-my-unread-tabs-became-walks-made-on-my-own-laptop-1905</link>
      <guid>https://dev.to/nazar-boyko/tabs-to-trails-my-unread-tabs-became-walks-made-on-my-own-laptop-1905</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My reading system is a row of browser tabs I opened because the title looked worth my time, then kept open for weeks, because reading takes a chair and a chair is where I spend the day already.&lt;/p&gt;

&lt;p&gt;Tabs to Trails turns those tabs into walks. A bookmarklet called "Walk this tab" drops the page you're on into a list, and each row shows how many minutes it takes to hear. When I've got twenty minutes, I pick 20. The app ticks what fits, oldest first, says how each piece will be read ("in full", "condensed from 27 min", "part 1 of 3"), and my laptop makes one MP3 of about that length. Gemma 4 rewrites the text for the ear and the Kokoro voice reads it, both on my machine. I scan a QR code with my phone, download the file, and put the phone in my pocket.&lt;/p&gt;

&lt;p&gt;Everything the walk needs is inside the MP3: a short intro, the piece, a chime at the measured middle with "You're halfway. If you're walking out and back, turn around now.", and one question for the way home. The phone needs no app and no Wi-Fi once the file is on it. I set one rule for the design: the app has to be done with me before I reach the door. The only dark screen in the product is the phone's player, and it says "Pocket your phone." and little else.&lt;/p&gt;

&lt;p&gt;Three things set it apart from "article to podcast". You bring the content, the thing you already meant to read. The length of your walk shapes it. And it stays faithful: the voice reads prose that fits as written, Gemma condenses only what doesn't fit, and a number guard checks every number in the rewrite against the source. Code blocks and tables are described rather than read, because a code block read aloud is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://nazboyko.github.io/tabs-to-trails/" rel="noopener noreferrer"&gt;Listen to two finished walks on the project page&lt;/a&gt;&lt;/strong&gt;: my own DEV post, asked for 10:00 and measured 10:15, and part one of Thoreau's "Walking", asked for 20:00 and measured 19:39.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/ZHeU8dnJVQ8" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;A note on the article in the video. It is "Every Software Developer Has Blamed…" by &lt;a class="mentioned-user" href="https://dev.to/sylwia-lask"&gt;@sylwia-lask&lt;/a&gt;. I picked it as a real-world test, and about 20 seconds of the audio version made it into the video. I hope Sylwia doesn't mind me using her post as the example 😇😇😇&lt;/p&gt;

&lt;p&gt;Go read the full post:&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/sylwia-lask/every-software-developer-has-blamed-3a1o" class="crayons-story__hidden-navigation-link"&gt;Every Software Developer Has Blamed…&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/sylwia-lask/every-software-developer-has-blamed-3a1o" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Relatable tech humor and shared grievances&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/sylwia-lask" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F3535771%2Fe22860d5-274b-43c9-819b-56b162e5bd5a.jpeg" alt="sylwia-lask profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/sylwia-lask" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Sylwia Laskowska
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Sylwia Laskowska
                
                &lt;img alt="Community Curator" class="community-leader-icon" src="https://assets.dev.to/assets/community-leader-icon-b72c9e74eff54916e5c46c962f47ba40c9f611a71f8b157511f9613f69c0001b.svg"&gt;
              
              &lt;div id="story-author-preview-content-4816094" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/sylwia-lask" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F3535771%2Fe22860d5-274b-43c9-819b-56b162e5bd5a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Sylwia Laskowska&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/sylwia-lask/every-software-developer-has-blamed-3a1o" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 8&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/sylwia-lask/every-software-developer-has-blamed-3a1o" id="article-link-4816094"&gt;
          Every Software Developer Has Blamed…
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/sylwia-lask/every-software-developer-has-blamed-3a1o" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;164&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;span class="crayons-story__favorited"&gt;
              &lt;span class="favorited-marker"&gt;
                &lt;span&gt;
                  

                &lt;/span&gt;
                &lt;span class="hidden"&gt;
                  

                &lt;/span&gt;
              &lt;/span&gt;
            &lt;/span&gt;
            &lt;a href="https://dev.to/sylwia-lask/every-software-developer-has-blamed-3a1o#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              114&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            4 min read
          &lt;/small&gt;
          
          &lt;div class="only-sidebar-menu-item trusted-visible-block print-hidden align-center"&gt;
                
                    
                
          &lt;/div&gt;

        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&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%2Fxmt26nvcey975zht3t72.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%2Fxmt26nvcey975zht3t72.png" alt="The home screen: five things waiting for a walk with their minutes, 20 minutes picked, and the walk it proposes: Thoreau's &amp;quot;Walking&amp;quot;, part one, 19 min in full, and a short text, 1 min, about 21 min for 20"&gt;&lt;/a&gt;&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%2F4qact6vumtzagodmv3tn.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%2F4qact6vumtzagodmv3tn.png" alt="The Ready screen: Walking, part one, by Henry David Thoreau and Why bread goes stale, 19:43 for 20:00, read in full, 2 tabs closed, a QR code to scan with the phone, Download MP3 and Download as audiobook, and the walk's timeline with the halfway cue at 9:48"&gt;&lt;/a&gt;&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%2Faswumfivs439mpmln6yz.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%2Faswumfivs439mpmln6yz.png" alt="The phone's playing screen, dark green: Pocket your phone, I'll tell you when you're halfway, the progress line with the halfway mark at 9:45, and back, play and forward buttons"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/nazboyko" rel="noopener noreferrer"&gt;
        nazboyko
      &lt;/a&gt; / &lt;a href="https://github.com/nazboyko/tabs-to-trails" rel="noopener noreferrer"&gt;
        tabs-to-trails
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Turn your reading backlog into a walk: a local Gemma 4 and Kokoro make an MP3 sized to your walk.
    &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;Tabs to Trails&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Turn your reading backlog into a walk.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Save tabs now. Walk them later.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/tabs-to-trails/docs/screenshots/home.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Ftabs-to-trails%2FHEAD%2Fdocs%2Fscreenshots%2Fhome.png" alt="The home screen: five things waiting for a walk with their minutes, 20 minutes picked, and the walk it proposes: Thoreau's &amp;quot;Walking&amp;quot;, part one, 19 min in full, and a short text, 1 min, about 21 min for 20"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Tabs to Trails keeps what you mean to read, measured in walking minutes. When you have twenty minutes, pick 20 and it proposes what fits. Your computer turns that into one MP3 of that length: Gemma 4 rewrites the text for listening and the Kokoro voice reads it, both on your own machine. You scan a QR code, put the phone in your pocket and go.&lt;/p&gt;
&lt;p&gt;The file holds everything the walk needs: a short intro, the reading, a chime and a cue at the halfway point so you know when to turn around, one question for the way home, and a sign-off. Its chapters let the phone's own player resume and skip, and an audiobook copy (.m4b) is one tap away. Once the file is on the phone, the walk needs nothing else.&lt;/p&gt;
&lt;p&gt;…&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/nazboyko/tabs-to-trails" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Node and TypeScript: a Hono server, a React front end, Gemma 4 through Ollama, Kokoro-82M through kokoro-js, ffmpeg for the MP3. Gemma 4 and Kokoro-82M are Apache-2.0; the full credits with licenses are in the README.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;link or text, saved to the list and read once
  -&amp;gt; picked by the walk's length
  -&amp;gt; plan: in full, or condensed to a word budget
  -&amp;gt; Gemma 4 rewrite per section, number guard
  -&amp;gt; Kokoro voice per sentence group
  -&amp;gt; measure, fit once, place the halfway cue
  -&amp;gt; MP3 + script + QR code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage writes into one folder per walk, so a build can stop and pick up where it was. I tested that with &lt;code&gt;kill -9&lt;/code&gt; in the middle of the voice stage; on restart the server said "Picking up 1 unfinished walk" and carried on from the third section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Twenty minutes is not twenty minutes
&lt;/h3&gt;

&lt;p&gt;The length is the product, so I measure it instead of trusting a words-per-minute guess. That guess was wrong in two places.&lt;/p&gt;

&lt;p&gt;The voice first. My calibration read a smooth 250-word passage at 188.7 words per minute, and then my own DEV post at 10 minutes came out at 10:42. Across that one post the same voice went from 130 to 174 words per minute per section, because numbers, colons and short paragraphs cost time, while characters per second stayed between 14.3 and 15.9. So the app now calibrates in characters per second, on a passage with numbers in it, and converts that into words per minute for each source.&lt;/p&gt;

&lt;p&gt;Then the model. Gemma does not hold a word count on a mild cut: asked for 697 words of a 939-word section, it returned 1,027, longer than the source. Short targets went the other way: asked for 823 of 1,037 words of Thoreau, it wrote 450. So each section gets one guarded shorten or lengthen pass, and whatever it over- or undershoots carries into the next ones. At the end, a fit pass looks at the measured audio once: more than 5% over, it shortens the largest condensed section; more than 8% under, it gives time back to the section that left out the most.&lt;/p&gt;

&lt;p&gt;The sample above took my DEV post from 1,726 words to 1,531, with 4.1 seconds of model time and 63.5 seconds of voice on an Apple M5 Max, for 10:15 of audio. Kokoro reads about nine times faster than real time here, so most of the wait is the voice. The app says "Lace up." while it works, and means it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The number guard
&lt;/h3&gt;

&lt;p&gt;The prompt tells Gemma to use only what is in the source. A small model adds a number from time to time. So after each rewrite, code compares every number in the output with the numbers in that section of the source, spelled-out ones included. A new number sends the section back once, naming it. If it comes back again, the script view draws a dashed outline around it: "Check this number."&lt;/p&gt;

&lt;p&gt;The guard once flagged a number that was right: Wikipedia's "one-hundred and fifty percent" came back as "150 percent", and the guard read it as two numbers. It adds them up now. It also misses what it cannot see: "the kid screen got the time" (my evening, in other words) came back as "the kid screen showed the time", with no number involved. The script view's footer says to review anything important, and I mean that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Read-along that lands on the word
&lt;/h3&gt;

&lt;p&gt;Kokoro speaks in groups of a few sentences, and the app knows where each group starts. Inside a group, my first version spread the time over the sentences by their characters. Measured against the pauses in the audio, those starts were a median 0.3 seconds off, enough to mark the wrong sentence. Now each start moves to the nearest real pause in the voice, preferring longer ones. In the two samples, 231 of 232 sentence starts sit on a pause of at least a quarter second, 40 milliseconds before the voice is loud again.&lt;/p&gt;

&lt;h3&gt;
  
  
  What broke
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kokoro reads at most 510 phoneme tokens per call and drops the rest without a word. A 294-character chunk of money figures was 1,237 phonemes. Each chunk is now measured with the same phonemizer and split until it fits.&lt;/li&gt;
&lt;li&gt;Wikipedia read its footnotes aloud: citation marks like [12] became "twelve". The app strips reference lists and footnotes now, and names what it left out.&lt;/li&gt;
&lt;li&gt;The first run read an HTML comment. My own post ends with &lt;code&gt;&amp;lt;!-- Thanks for participating! --&amp;gt;&lt;/code&gt;, and the voice said it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  I took it outside
&lt;/h3&gt;

&lt;p&gt;I didn't film the walks; the video above is a screen recording with the real audio. The 10- and 20-minute walks went without a hitch. On a longer one, a phone call interrupted "Play it here" and I had to open the page and start again, so for long walks the downloaded MP3 or the audiobook file is the better way.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I cut and what's next
&lt;/h3&gt;

&lt;p&gt;I cut a model comparison with &lt;code&gt;gemma4:e2b&lt;/code&gt;, because it wasn't on my laptop and I didn't want to download a model for one table. Next I'd try a pace setting per voice, and test the audiobook file and the reminder on more phones than mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;The documents I most want to walk with are the ones I can't put in a demo. With open weights on my own machine, the text goes from the clipboard to my disk and nowhere else, so the app works for anything I'm allowed to process on my own machine.&lt;/p&gt;

&lt;p&gt;It also changes what a walk costs. There is no meter, so I can make one for a ten-minute errand and throw it away. Paste mode works with the network off once Ollama and the voice are installed.&lt;/p&gt;

&lt;p&gt;And anyone can change it. The model is one setting (&lt;code&gt;OLLAMA_MODEL&lt;/code&gt;), the prompts are plain text in the repository, and the voice is an Apache-2.0 model you can swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt; through Ollama) does the language work: it condenses sections to a word target, describes code and tables for the ear, rates how much each section matters, and writes the closing question. The code around it keeps it honest: word budgets, the number guard, and a measured fit to the walk's length.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Six-Year-Old Can't Do Five Things at Once, So His Screen Shows One</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Mon, 05 Oct 2026 03:30:12 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/my-six-year-old-cant-do-five-things-at-once-so-his-screen-shows-one-o1m</link>
      <guid>https://dev.to/nazar-boyko/my-six-year-old-cant-do-five-things-at-once-so-his-screen-shows-one-o1m</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My son is six and started kindergarten a month ago. A school morning asks him for five things: eat, brush teeth, get dressed, shoes and jacket, backpack. Breakfast is easy, because breakfast is one pancake with honey and he is very consistent about that. The rest is where it falls apart. He starts getting dressed, remembers his teeth, wants to play, and a few minutes later he is doing all three at once, which means none of them.&lt;/p&gt;

&lt;p&gt;I noticed that he is fine with any single step. What he cannot hold is the list. So I built a screen for him that never shows a list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One Thing&lt;/strong&gt; is a small app for our home network. I type the routine in plain words. A Gemma 4 model running on my laptop turns it into a short mission with a theme (I went with rockets). I read every step and fix what I don't like. Only after I approve it does it reach the kid screen on the tablet, and there it shows exactly one step at a time: one big emoji, a few words, and one button.&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%2Fcwm2cg8x0s5nxsdpwblj.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%2Fcwm2cg8x0s5nxsdpwblj.png" alt="The kid screen at tablet size: a large pancake emoji, the step title " width="800" height="1151"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every rule on that screen comes from something I see on our mornings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One step on screen, never a list&lt;/td&gt;
&lt;td&gt;The list is the problem.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every step is read aloud&lt;/td&gt;
&lt;td&gt;He is only starting to read.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The button has to be held, not tapped&lt;/td&gt;
&lt;td&gt;A six-year-old will happily tap through eight steps in two seconds. Holding it takes a decision.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brushing teeth is a timer he can't skip&lt;/td&gt;
&lt;td&gt;"Until the timer ends" is easier to accept than "until Dad says so".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing ever fails&lt;/td&gt;
&lt;td&gt;No red, no "too slow", no points. If a timer runs out, the app asks once whether he is still on it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The end is always visible&lt;/td&gt;
&lt;td&gt;A row of dots with a small rocket on the current one. A routine with a visible end feels shorter.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I approve every mission first&lt;/td&gt;
&lt;td&gt;He never talks to the model and never sees a word I have not read.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/c2LWqJxeRFA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try the kid screen yourself:&lt;/strong&gt; &lt;a href="https://nazboyko.github.io/one-thing/demo/" rel="noopener noreferrer"&gt;nazboyko.github.io/one-thing/demo&lt;/a&gt;. It plays the built-in sample mission in your browser. Hold the amber button; a quick tap does nothing.&lt;/p&gt;

&lt;p&gt;In the video (79 seconds) I type a routine for getting ready to go outside and give it the theme "space walk". Gemma writes the mission in 2.6 seconds. I scroll through the steps, approve it, and the kid screen plays the mission to the finale. The typing is sped up 2x and labeled. The kid screen runs at normal speed, and you can hear it say each step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stands.&lt;/strong&gt; This is the first working version, built in one evening. My son has not used it yet. I wanted it finished and checked before it goes in front of him, and I wanted the first try to be a calm one, not one squeezed in before a deadline. Everything in this post is about the idea and how it is built, not about results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/nazboyko" rel="noopener noreferrer"&gt;
        nazboyko
      &lt;/a&gt; / &lt;a href="https://github.com/nazboyko/one-thing" rel="noopener noreferrer"&gt;
        one-thing
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A mission board that shows a kid exactly one step at a time. Runs offline on Gemma.
    &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;One Thing&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A mission board that shows a kid exactly one step at a time. Runs offline on Gemma.&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/one-thing/docs/kid-step.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fone-thing%2FHEAD%2Fdocs%2Fkid-step.png" alt="The kid screen on a tablet: a pancake emoji, the step title 'Fuel up', a timer at 9:57, and a round amber button labeled 'Hold when done' with its ring partly filled" width="49%"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/one-thing/docs/kid-finale.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fone-thing%2FHEAD%2Fdocs%2Fkid-finale.png" alt="The last screen: a rocket, 'Mission complete', and 'You have 14 minutes to play before liftoff.'" width="49%"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try the kid screen in your browser:&lt;/strong&gt; &lt;a href="https://nazboyko.github.io/one-thing/demo/" rel="nofollow noopener noreferrer"&gt;nazboyko.github.io/one-thing/demo&lt;/a&gt;
(the built-in sample only; writing new missions needs Gemma on your own computer).&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why it exists&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;I built this for my six-year-old son. Routines with several steps are the
hardest part of his day: on a school morning he wants to get dressed, brush
his teeth and play all at once, and none of it gets done. A list does not
help him, so this screen never shows one.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;A parent describes a routine in plain words and picks a theme.&lt;/li&gt;
&lt;li&gt;Gemma 4 runs on the laptop through Ollama and writes a short mission as
JSON that has to match a schema. The server tidies it, checks every rule
and asks the model once more if something is off.&lt;/li&gt;
&lt;li&gt;The…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nazboyko/one-thing" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The open-source AI at the center is &lt;strong&gt;Gemma 4&lt;/strong&gt;, Google's open-weight model (&lt;code&gt;gemma4:e4b&lt;/code&gt;, with thinking turned off). &lt;strong&gt;Ollama&lt;/strong&gt;, an open-source runtime, runs it on my laptop. Around them sits one &lt;strong&gt;Go&lt;/strong&gt; binary that uses only the standard library, with a &lt;strong&gt;React&lt;/strong&gt; front end compiled into it. There is no database, no account and no cloud. Saved missions are JSON files in a folder, and the only network call the app makes is to Ollama on localhost.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parent's words ──&amp;gt; Go server ──&amp;gt; Ollama (Gemma 4, on the laptop)
                       │                    │
                       │   JSON that matches a schema
                       │&amp;lt;───────────────────┘
                 sanitize, validate, retry once
                       │
                 parent reads, edits, approves
                       │
                 tablet shows one step at a time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Getting a small model to return something I can trust.&lt;/strong&gt; I don't want prose from the model. I want a mission my code can check. So the request to Ollama carries a JSON schema, and Gemma has to answer in that shape. A mission holds three to eight steps, and each step is described like this:&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="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"emoji"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="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;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="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;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"until_done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"for_duration"&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;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="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;A schema guarantees the shape, not the sense. So every answer goes through two more functions. &lt;code&gt;Sanitize&lt;/code&gt; fixes what is safe to fix: stray whitespace, Title Case in step titles, a timer outside the allowed range, an "emoji" that is not one. &lt;code&gt;Validate&lt;/code&gt; returns plain field errors such as &lt;code&gt;steps[2].title: too many words (at most 6)&lt;/code&gt;. If any remain, the server sends them back to the model as a follow-up message and asks once more.&lt;/p&gt;

&lt;p&gt;Here is what I typed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Routine:&lt;/strong&gt; School morning for a 6-year-old: breakfast, brush teeth, get dressed, shoes and jacket, backpack. Brushing teeth is the hardest part, split it into short pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theme:&lt;/strong&gt; rocket launch&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And this is the raw answer, with only the line breaks tidied:&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ready for rocket launch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intro"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Captain, it is time to prepare your rocket for today's mission!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&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;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fuel up the rocket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Captain, eat your breakfast now. Keep fueling up!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"until_done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Polish top deck"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brush your top teeth. Make them shiny for blast off!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"for_duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&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;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Polish bottom deck"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brush your bottom teeth. Mission control needs clean teeth!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"for_duration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&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;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Suit up the rocket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Captain, get dressed now. Your flight suit is ready!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"until_done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Seal the landing gear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Put on your shoes and jacket. Time to secure your gear!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"until_done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;"emoji"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pack the rocket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"say"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Captain, pack your backpack. Make sure everything is inside!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"until_done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&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;"finale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mission accomplished, Captain! You are ready for launch!"&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;I would still change one line before approving it. "Keep fueling up!" sounds like "eat more", which the prompt tells the model never to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numbers from my laptop&lt;/strong&gt; (Apple M5 Max, 64 GB, Ollama 0.35, model already loaded):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Seconds per mission&lt;/th&gt;
&lt;th&gt;Valid on the first try&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thinking off&lt;/td&gt;
&lt;td&gt;2.4 on average (1.7 to 2.8)&lt;/td&gt;
&lt;td&gt;22 of 22 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thinking on (one run)&lt;/td&gt;
&lt;td&gt;11.3&lt;/td&gt;
&lt;td&gt;No: breakfast became a ten-minute timed step&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 22 runs went through the server while I tuned the prompt (school mornings, a bedtime, an after-school routine), and the time includes parsing and validation. The retry never fired; it is covered by tests against a fake Ollama. Thinking made Gemma several times slower and worse at this job, so it stays off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompt needed real fixes.&lt;/strong&gt; The first version returned valid JSON every time, and as a parent I would still have rejected half of it. Asked for "breakfast", it invented cereal. Asked to split tooth brushing because it is the hardest part, a bedtime run kept it as one two-minute step. So &lt;a href="https://github.com/nazboyko/one-thing/blob/main/internal/llm/prompt.txt" rel="noopener noreferrer"&gt;the prompt&lt;/a&gt; now says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Use only the actions and things the parent names. Do not invent foods,
  clothes or objects. If the parent says "breakfast", say "Eat your
  breakfast".
- If the parent says a part is hard or asks to split it, that part MUST become
  two or three separate steps. Brushing teeth becomes "top teeth" and then
  "bottom teeth", each its own timed step.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brushing has been split in every run since. Two more fixes came from reading output the same way: one run said "eat your breakfast until done", and another made breakfast a two-minute timed step, which would have locked the button while he eats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What did not work.&lt;/strong&gt; In 3 of 8 morning runs that I read line by line, Gemma folded "shoes and jacket" into just "shoes". Four rounds of prompt changes fixed everything else I found, but not this. The screenshot below shows it happening again, unedited: step 5 has no jacket. This is why the parent reads every step before approving, and why I would not let a model talk to my son directly.&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%2Favt9x32qo5jw76hdkg5x.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%2Favt9x32qo5jw76hdkg5x.png" alt="The parent screen: a box to describe the routine, then the mission Gemma wrote as six editable steps, each with an emoji, a title, the spoken line, a kind and seconds, and an " width="800" height="1202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small things that turned out to matter:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hold button fires after 0.8 seconds, measured from timestamps, not animation frames. Quick taps do nothing, and letting go early drains the ring.&lt;/li&gt;
&lt;li&gt;Step timers come from timestamps too, so a throttled tab stays correct. &lt;code&gt;?speed=20&lt;/code&gt; in the address makes them twenty times faster, which is how the screen got tested without brushing anyone's teeth for a minute at a time.&lt;/li&gt;
&lt;li&gt;Gemma once returned a colon as the "emoji" for both teeth steps, and my validator let it through: it only rejected letters and digits. Now any plain ASCII there is rejected and replaced with a star.&lt;/li&gt;
&lt;li&gt;The first voice the app picked sounded robotic. It now uses only voices installed on the device, never a network one: Premium first, then Enhanced, then a short list of natural voices, and never the novelty voices macOS ships. On the parent screen I can pick another voice and test it.&lt;/li&gt;
&lt;li&gt;If I set a time to leave, the last screen says how long is left: "You have 14 minutes to play before liftoff."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I cut.&lt;/strong&gt; A speed comparison with the smaller &lt;code&gt;gemma4:e2b&lt;/code&gt;. It was not on the laptop, so I have no numbers for it. It was one evening, and the kid screen got the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is not tested yet.&lt;/strong&gt; It has run in a browser in tablet mode on my laptop, not on a real tablet, and not with the person it is for. Speech voices differ between devices: for a natural one on a Mac or iPad, you download a Premium or Enhanced English voice in Settings &amp;gt; Accessibility &amp;gt; Spoken Content (Read &amp;amp; Speak on newer macOS), and the app picks it by itself. I expect the timers to need tuning once a real six-year-old is holding the button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this project it is not a nice extra. It decides whether I would use the thing at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The input is a description of where my child struggles.&lt;/strong&gt; "Brushing teeth is the hardest part." "He only eats one thing for breakfast." That is what I type into the box. With open weights on my own laptop, those sentences go from my keyboard to my own memory and nowhere else. I did not have to read anyone's privacy policy to decide that this was fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not need the internet.&lt;/strong&gt; The page, the Lexend font, the model and the saved missions all live on the laptop. A morning routine should not depend on somebody else's uptime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs nothing, so I can be picky.&lt;/strong&gt; I generated dozens of missions while tuning the prompt. There is no meter running, no key to protect, and no account to create for a tool that a six-year-old uses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone can change how it thinks.&lt;/strong&gt; The system prompt is a text file in the repository, and the model is one line of configuration. If your kid is four, or ten, or needs steps cut even smaller, you edit a paragraph of English, not a product.&lt;/p&gt;

&lt;p&gt;A hosted API would probably have written similar JSON. But I would be sending notes about my son to a third party every time, paying per call for something that runs a few seconds a week, and tying his morning to a service that can change or disappear. The weights are a file on my disk, and the whole point of this tool is that every day looks the same. For a family tool, local and open is simply the right shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 runs locally through Ollama and writes every mission. It is used with schema-constrained output, a validation and retry loop, and a system prompt written for one specific job: turning a parent's description into single, physical, kid-sized steps.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
    </item>
    <item>
      <title>Side Project Graveyard: a proper burial for dead repos</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Sun, 04 Oct 2026 17:27:41 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/side-project-graveyard-a-proper-burial-for-dead-repos-2hma</link>
      <guid>https://dev.to/nazar-boyko/side-project-graveyard-a-proper-burial-for-dead-repos-2hma</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I have a folder of side projects that stopped at some point and never really came back. Some of them were useful for a while. Some made it into production. Some survived for two days, got a README, and then spent the next three years pretending they were merely "paused".&lt;/p&gt;

&lt;p&gt;So instead of deleting them, I built them a cemetery. 😂😂😂&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fegr0t9iolrm6ufw5hnyl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fegr0t9iolrm6ufw5hnyl.gif" alt="A tour of the site: the gate, the yard of stones, a cause-of-death filter, one grave and a candle being lit" width="600" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each project gets a stone: its years, an epitaph, a cause of death, the stack, the last commit message, and the one thing it taught me. You can walk the yard, hover a cause of death to see which graves it took, read an obituary, and light a candle.&lt;/p&gt;

&lt;p&gt;No stone has its own artwork. A stone's height comes from how long the project lived, its moss and cracks from how long ago it died, its marks from the cause of death, its brass and flowers from being retired with honour. A two-day todo app is a low marker with a wooden tag. A framework that never had a user is a mausoleum.&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%2Fu8ywv269qb1nys7nz5zf.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%2Fu8ywv269qb1nys7nz5zf.png" alt="The yard: eighteen stones of different heights and shapes at dusk" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The home page opens with a register: 14 buried, 3 retired with honour, average lifespan 355 days, most common cause of death "Lost interest". Nobody typed those numbers. A grave points to its cause and its stack as Sanity documents, and Astro counts them during the build.&lt;/p&gt;

&lt;p&gt;There are 18 graves. Five are public repos of mine, written up from their GitHub metadata and READMEs. The other 13 are invented, and you have met them before: the todo app that lived two days, the framework with a router, an ORM and no users, the newsletter with one subscriber and a 100% open rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Live site: &lt;a href="https://side-project-graveyard.boyko-nazar.workers.dev" rel="noopener noreferrer"&gt;side-project-graveyard.boyko-nazar.workers.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three clicks worth making:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The register on the home page, then hover "Lost interest" in the legend and watch the other graves step back.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://side-project-graveyard.boyko-nazar.workers.dev/rip/sunday-letter/" rel="noopener noreferrer"&gt;Sunday Letter&lt;/a&gt;, the newsletter tool. Read the last line of the obituary.&lt;/li&gt;
&lt;li&gt;The candle on any grave. It counts in your browser and nowhere else, and the page says so. Go back to the yard afterwards: that grave is lit there too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The animated tour at the top is a Playwright recording of the live site, converted to a GIF with ffmpeg.&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%2Fyrmbr5heus08507xztyp.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%2Fyrmbr5heus08507xztyp.png" alt="Fretwork's grave with nine candles lit and the line " width="799" height="608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/nazboyko" rel="noopener noreferrer"&gt;
        nazboyko
      &lt;/a&gt; / &lt;a href="https://github.com/nazboyko/side-project-graveyard" rel="noopener noreferrer"&gt;
        side-project-graveyard
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &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;Side Project Graveyard&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Every repo deserves a proper burial.&lt;/p&gt;
&lt;p&gt;I have a folder of side projects that stopped. This site gives each of them a stone: an epitaph, a cause of death, the stack it was built with, its last commit message, and the one thing it taught me. The content lives in Sanity, the site is Astro, and I built it for the DEV × Sanity Challenge by prompting Claude Code.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Live site: &lt;a href="https://side-project-graveyard.boyko-nazar.workers.dev" rel="nofollow noopener noreferrer"&gt;https://side-project-graveyard.boyko-nazar.workers.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Studio: &lt;a href="https://side-project-graveyard.sanity.studio" rel="nofollow noopener noreferrer"&gt;https://side-project-graveyard.sanity.studio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sanity project ID &lt;code&gt;20pcz8by&lt;/code&gt;, dataset &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;Build log, with every prompt and every failure: &lt;a href="https://github.com/nazboyko/side-project-graveyard/docs/BUILD_LOG.md" rel="noopener noreferrer"&gt;docs/BUILD_LOG.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;tbody&gt;
&lt;tr&gt;
    &lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/side-project-graveyard/docs/screenshots/home-1440.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fside-project-graveyard%2FHEAD%2Fdocs%2Fscreenshots%2Fhome-1440.png" alt="The home page at dusk: the gate, the keeper's note, the register, the legend of causes, and eighteen stones of different shapes and heights along a path." width="560"&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/side-project-graveyard/docs/screenshots/grave-retired-1440.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fside-project-graveyard%2FHEAD%2Fdocs%2Fscreenshots%2Fgrave-retired-1440.png" alt="Kindness Chain's grave page: a pale plaque with its name on brass, laurel and flowers, a candle button, the lifeline and the autopsy sheet." width="280"&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/nazboyko/side-project-graveyard/docs/screenshots/grave-390.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnazboyko%2Fside-project-graveyard%2FHEAD%2Fdocs%2Fscreenshots%2Fgrave-390.png" alt="PDF Viewer SDK on a phone: a very small marker stone with a wooden tag beside it, on mostly empty ground." width="140"&gt;&lt;/a&gt;&lt;/td&gt;
  &lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;All six screenshots, at 1440, 768 and 390 pixels, are in &lt;a href="https://github.com/nazboyko/side-project-graveyard/docs/screenshots" rel="noopener noreferrer"&gt;docs/screenshots&lt;/a&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Content model&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Four document types, defined in &lt;a href="https://github.com/nazboyko/side-project-graveyard/studio/schemaTypes" rel="noopener noreferrer"&gt;&lt;code&gt;studio/schemaTypes/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Fields&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;project&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A grave&lt;/td&gt;
&lt;td&gt;name, slug, epitaph, status (buried, retired, undead), born and died dates, cause, stack, last commit, mood at death, lines of code, obituary, lesson, repository, and an&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nazboyko/side-project-graveyard" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;studio/&lt;/code&gt; is the Sanity Studio: four schema types, the desk structure, the seed content, the script that set the monument fields.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;web/&lt;/code&gt; is the Astro site: one file of GROQ queries, one file that turns a record into a monument, one of date and stats maths, all tested; five page templates; one stylesheet bundle; every drawing hand-written SVG; five generated images for the scene and the stone, in &lt;code&gt;web/src/assets/scene/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/DESIGN_BRIEF.md&lt;/code&gt; is the redesign plan, written from two outside reviews.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/visual-enhancement/&lt;/code&gt; is the visual audit a second agent wrote before it touched the materials, and &lt;code&gt;docs/image-prompts.md&lt;/code&gt; has the prompts for the five images.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/BUILD_LOG.md&lt;/code&gt; is the prompt log. Every prompt is in it word for word, and so is every failure.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; holds the rules the session ran under.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Build Process
&lt;/h2&gt;

&lt;p&gt;I used two tools. Claude Code built the site and the redesign, in one session. Codex, a second agent, wrote a visual audit and implemented the material pass at the end. Claude Code then reviewed that pass and corrected it.&lt;/p&gt;

&lt;p&gt;Before the session I wrote two things by hand. A plan: the concept, the schema, and one prompt for each of eight phases. And a rules file: one phase is one branch and one pull request, merge when CI is green, write every prompt and every failure into the build log, stop only when a step needs my browser. Then I typed one kickoff prompt and the model read the phase prompts from the plan.&lt;/p&gt;

&lt;p&gt;The numbers: 16 pull requests, and CI never failed on one of them. The build took 8 phases and about 95 minutes of the model's work; the first five phases, from a bare Studio scaffold to 42 built pages, took 52. Then the session waited seven hours for me to click through a hosting dashboard. The redesign took five more pull requests and about 65 minutes, with two stops for my review. The material pass was one pull request by the second agent, and the review of it took Claude Code 66 minutes. A final check before publishing found twelve dependencies without a credit in the README; that fix was the sixteenth pull request. 25 messages from me in all.&lt;/p&gt;

&lt;p&gt;The parts worth telling are the ones where the prompt and the result disagreed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My kickoff was wrong in three places.&lt;/strong&gt; It said my global git excludes would hide the tooling folder. They did not. It assumed a git identity existed on the machine. None did. It asked for a job-level &lt;code&gt;if: hashFiles(...)&lt;/code&gt; in the CI workflow, which GitHub Actions does not allow. The model caught all three before anything failed: it ran &lt;code&gt;git check-ignore&lt;/code&gt; before staging, looked up the author on my earlier public repos before the first commit, and moved the condition to the steps. The build log lists them under "what failed", because my prompt failed, even if no command did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The schema got its real test from a broken file.&lt;/strong&gt; The prompt asked for a date rule: a death date is required unless the project is undead, and it cannot come before the birth date. The model wrote 17 validation rules with messages in the voice of the graveyard keeper. Then it could not check them on screen, because the Studio stops at a login and it does not sign in to my accounts. So it wrote three broken graves into a file and ran &lt;code&gt;sanity documents validate&lt;/code&gt; on it. Seven errors came back, among them "A project cannot die before it is born." and "Only the undead may go without a date of death."&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%2Fqyql99jzskk64dxuuhk4.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%2Fqyql99jzskk64dxuuhk4.png" alt="Sanity Studio: the Died field in red with the message " width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;astro add&lt;/code&gt; installed React into a site with a no-React rule.&lt;/strong&gt; &lt;code&gt;npx astro add @sanity/astro&lt;/code&gt; added &lt;code&gt;react&lt;/code&gt;, &lt;code&gt;react-dom&lt;/code&gt;, &lt;code&gt;sanity&lt;/code&gt; and &lt;code&gt;styled-components&lt;/code&gt; as direct dependencies. They are peers of the integration, for an embedded Studio this site does not have. The model removed them and tried to keep them off disk with &lt;code&gt;legacy-peer-deps&lt;/code&gt;. That broke &lt;code&gt;astro check&lt;/code&gt;, which then printed a misleading request to install &lt;code&gt;@astrojs/check&lt;/code&gt;, a package that was already there. The cause was a pruned peer of an unrelated package. The model found it by importing the package by hand, dropped the setting, and let npm install the peers. Same phase: npm installed TypeScript 7, &lt;code&gt;astro check&lt;/code&gt; wants 6. Three retries in 13 minutes, all in the log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The screenshots looked right and the page was wrong.&lt;/strong&gt; After building the pages the model took screenshots at three widths and they were fine. Then it read the text of the links in the browser and found "Previous graveEverything.js" and "Lost interest5graves". Two spans sat side by side with no space between them, so a screen reader would have read them as one word. No screenshot shows that. Lighthouse came out at 100 in all four categories after the fix, and I trust the number more because of what was found before it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plan said Cloudflare Pages. The dashboard gave me a Worker.&lt;/strong&gt; "Connect to Git" now creates a Worker with static assets, built by Workers Builds. My plan, my rules file and the README the model had drafted all said Pages. I also told the model to continue before the hosting existed and gave it the URL I expected to get. It did not take my word for it. The name did not resolve, there was no &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;sanity hooks list&lt;/code&gt; was empty. It deployed the Studio, wrote the webhook test script, and stopped again with that evidence. When I came back with the real URL, the fix was an 11-line &lt;code&gt;wrangler.jsonc&lt;/code&gt; and a reworded README. A publish in the Studio now reaches the live site within a minute. I measured it with a script that stamps the footer line and polls the page.&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%2F8xe3rnfrzq0jpuqglej4.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%2F8xe3rnfrzq0jpuqglej4.png" alt="Cloudflare version history: two builds named " width="799" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The review changed the order of the graves.&lt;/strong&gt; The first version listed the newest death first. That put my five real repos in the top rows, with sober one-liners from their READMEs, and pushed the better epitaphs below the fold. The review phase flagged it. The graves are now ordered oldest first, which is how a cemetery grows: the old part is by the gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The redesign started from a working site that looked like a dashboard.&lt;/strong&gt; Five tiles, a row of pills, eighteen identical cards. I had two outside design reviews done, and one of them proposed a map in TypeScript with a visual entry per project. That would have put content in code and broken for the next grave. The brief I wrote instead had one rule: the stone is computed from the record. The model added one field to the cause (&lt;code&gt;motif&lt;/code&gt;) and one optional group to the grave (&lt;code&gt;monument&lt;/code&gt;: shape, relic, inscription, figures), set them with one script, and wrote &lt;code&gt;describeMonument(project, now)&lt;/code&gt;, which knows no grave by name. A test runs it on the seed file and checks the brief's own counts: 4 ancient stones, 6 aged, 2 settled, 5 fresh, 1 disturbed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model was not allowed to ship anything it had not seen.&lt;/strong&gt; Every visual branch went through at least three rounds of screenshots at three widths, and the model read every image. 19 drawings were redone because of what it saw. The sky had no warm band. The fence strip read as a barcode. Moss looked like polka dots. The smallest stone floated in the sky above the fence because the candle button was pinned to the ground instead of the stone. A shovel pushed a marker off its tag. Signpost, briefcase and crates were brown on brown ground and disappeared. None of these would have shown in a type check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second agent added the photographs, and the first one reviewed its work.&lt;/strong&gt; The redesign was drawn in CSS and SVG and still looked flat. I generated five images in ChatGPT (image model Sol 5.6, effort set to medium, two candidates per image, and I kept the one with the better detail and the right amount of objects in the frame): a wide dusk cemetery, a portrait version for phones, a limestone tile, a soil tile and a strip of grass. Codex wrote an audit of the site first, then put the photographs behind the gate and the limestone on the stones, in one pull request, and reported Lighthouse accessibility 100. Claude Code reviewed that pull request from its own screenshots. It measured the pixels behind the tagline and got 2.65:1. Lighthouse cannot measure text over an image, so the 100 had not covered it. It also blocked the image to test the fallback and found half of the old fence in the dark, found a pale gate post floating in the sky of every grave page, and found that one of its own relics from the day before had been lying across "lived less than a day" the whole time, in a screenshot it had committed itself. Eleven fixes went in. Firefox would not start for the model on my machine, so I opened the preview in Firefox myself, and the log says the check was mine. The last fix came after that: the relics had always had an ink outline in Safari and none in Chrome, and now they are flat in both.&lt;/p&gt;

&lt;p&gt;One of the six repos I had named as real graves turned out to be private. The prompt said to skip it in that case, and the model did. For the five real ones it left "mood at death" and "lines of code" empty. Its note in the log: they would have been guesses.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I cut and why
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A form where visitors bury their own projects. It needs accounts or moderation, and neither fits a weekend.&lt;/li&gt;
&lt;li&gt;A candle count shared between visitors. It needs a write path, and the dataset is read-only from the site on purpose. The count is local and the page says so.&lt;/li&gt;
&lt;li&gt;The Studio embedded in the Astro site. It needs server output, and this site is static files.&lt;/li&gt;
&lt;li&gt;A library of photographed stones. The brief planned cutouts for every shape. Five generated images replaced it: two for the scene, three as materials. The stone shapes are still CSS masks, the words on them are still HTML, and the relics are still hand-written SVG.&lt;/li&gt;
&lt;li&gt;A backdrop I can change from the Studio. The field exists and works, but one wide image cropped for a phone is not the portrait image, so it stays empty and the two local images are the default.&lt;/li&gt;
&lt;li&gt;A gate that swings open on scroll, leaves, fireflies, a crow. One fog band and one cat stayed.&lt;/li&gt;
&lt;li&gt;Every animation except the candle, the undead grave's light, the fog and a 3-pixel lift on hover.&lt;/li&gt;
&lt;li&gt;The App SDK and Workflows bonus. I wanted one path done well.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I would do differently
&lt;/h3&gt;

&lt;p&gt;I would check the hosting dashboard before writing the plan. Seven hours of this build were me finding out that a product had been renamed.&lt;/p&gt;

&lt;p&gt;I would also give the model a way to see the Studio. It tested the schema well without it, but I am the only one who has looked at the desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Project ID: &lt;code&gt;20pcz8by&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dataset: &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;Studio: &lt;a href="https://side-project-graveyard.sanity.studio" rel="noopener noreferrer"&gt;https://side-project-graveyard.sanity.studio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Schema: &lt;a href="https://github.com/nazboyko/side-project-graveyard/tree/main/studio/schemaTypes" rel="noopener noreferrer"&gt;&lt;code&gt;studio/schemaTypes/&lt;/code&gt;&lt;/a&gt;. Four types: &lt;code&gt;project&lt;/code&gt;, &lt;code&gt;cause&lt;/code&gt;, &lt;code&gt;tech&lt;/code&gt;, &lt;code&gt;siteSettings&lt;/code&gt;. The grave has an optional Monument group; the cause has a required mark.&lt;/li&gt;
&lt;li&gt;Seed content: &lt;a href="https://github.com/nazboyko/side-project-graveyard/blob/main/studio/seed/graveyard.ndjson" rel="noopener noreferrer"&gt;&lt;code&gt;studio/seed/graveyard.ndjson&lt;/code&gt;&lt;/a&gt;, 41 documents.&lt;/li&gt;
&lt;/ul&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%2F1uxz04in6kkgooxd0zbr.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%2F1uxz04in6kkgooxd0zbr.png" alt="Sanity Studio: the cause " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The dataset answers without a token. This query returns each cause of death with its number of graves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://20pcz8by.api.sanity.io/v2026-09-01/data/query/production?query=*[_type=="cause"]{title,"graves":count(*[_type=="project"%26%26references(^._id)])}|order(graves desc)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The content model is small on purpose. Cause and stack are references, so the site can count them. The death date is validated against the birth date and the status. The epitaph is capped at 120 characters because that is what fits on the stone, the inscription at 28 ("The mason charges by the letter."), the figures at three ("A grave is not a dashboard."). The settings are one document that the Studio will not let you duplicate or delete.&lt;/p&gt;

&lt;p&gt;Every part of a stone comes from these fields: height from the dates, weathering from the date of death, marks from the cause's &lt;code&gt;motif&lt;/code&gt;, brass from the status, shape, relic and inscription from the optional &lt;code&gt;monument&lt;/code&gt;. The cat sits on whichever grave was published last, so it moves when I publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;The full agent transcript is not attached: it contains my local notes and paths that are not part of the project. Every prompt is in &lt;a href="https://github.com/nazboyko/side-project-graveyard/blob/main/docs/BUILD_LOG.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/BUILD_LOG.md&lt;/code&gt;&lt;/a&gt; instead, word for word: one entry per phase, with what came out, what failed, and what the model decided on its own. The material pass ran in a Codex session, and its prompts are in the same log.&lt;/p&gt;

&lt;p&gt;Seed content is a mix of five real dead projects of mine and 13 invented ones. The stats are computed from all of them at build time.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>When to Stop Asking the AI to Fix Its Own Fix?</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Thu, 01 Oct 2026 04:34:23 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/when-to-stop-asking-the-ai-to-fix-its-own-fix-183i</link>
      <guid>https://dev.to/nazar-boyko/when-to-stop-asking-the-ai-to-fix-its-own-fix-183i</guid>
      <description>&lt;p&gt;A few weeks ago I ran into one of those bugs that looked simple enough that I kept thinking the next fix would probably solve it. A small Node.js and TypeScript service was pulling jobs from several APIs, normalizing them and storing them in PostgreSQL. The background worker finished without errors and logged that the import had completed successfully, but when I checked the database, some of the jobs simply weren't there.&lt;/p&gt;

&lt;p&gt;Running the same import again would often make more of them appear. Not all of them, just more, which made the problem look like some kind of timing issue. So I asked the AI to take a look. 😎&lt;/p&gt;

&lt;p&gt;The first suggestion was to add retries around the database writes. That seemed reasonable, but it didn't help. Then came a delay, changes to the transaction boundaries, a higher retry count and eventually some extra error handling around individual inserts. Four rounds later, the symptom was exactly the same, while the diff had spread through the worker, the database layer and the tests.&lt;/p&gt;

&lt;p&gt;The actual bug turned out to be an async callback inside a forEach. None of the suggested fixes had touched it.&lt;/p&gt;

&lt;p&gt;I don't think the AI was being dumb here. I think I kept handing it the same information and asking for a different answer.&lt;/p&gt;

&lt;p&gt;Look at the symptom from the AI's side for a second. Rows go missing and a rerun brings more of them back and nothing in the description says where they went, so it smells like timing, right? Every fix it suggested followed that smell from the first round to the last.&lt;/p&gt;

&lt;p&gt;First it added retries around the database writes. Then came a delay, different transaction boundaries and a higher retry count. By the last round, it was catching individual insert errors so one bad record wouldn't affect the rest of the batch.&lt;/p&gt;

&lt;p&gt;Those guesses weren't unreasonable. If all you know is that some rows go missing and a rerun brings some of them back, a timing issue is an obvious place to look. I probably would have started there too. 🙄&lt;/p&gt;

&lt;p&gt;The funny part is that there really was a timing problem, just not the one we were trying to fix. The writes weren't happening too early or taking too long. The worker simply wasn't waiting for them to finish.&lt;/p&gt;

&lt;p&gt;And that's where the debugging nightmare really started, because by that point neither the AI nor I seemed to understand the problem any better than when we began. 😕 By the fourth attempt, the suggestions had started circling back to the same ideas, just with slightly different settings. The last one went in another direction, but it still didn't solve the problem. If an insert fails and the code catches the error and continues, the row is still missing; now the failure is just quieter.&lt;/p&gt;

&lt;p&gt;Here's what I think was going on. Every round the AI got the same clues (some rows are missing and a rerun brings back more). Unless a model gets logs or a way to run things, it works from the description and the code it can see, and "still missing" adds nothing to either of those. So it guessed again from the same clues, except that now the conversation also held every guess that had already failed, and every new answer had to work on top of the code the previous answers had left behind in the worker and the database layer.&lt;/p&gt;

&lt;p&gt;At some point I realized we weren't really debugging anymore. We were just generating new guesses from the same two facts: some rows were missing, and running the import again brought some of them back.&lt;/p&gt;

&lt;p&gt;And every failed fix made the next round a little messier. Now the AI wasn't only looking at the original code. It was also looking at retries, delays and transaction changes that had been added during the previous attempts. The code was changing, but my understanding of the bug wasn't.&lt;/p&gt;

&lt;p&gt;Could a better prompt have helped? Maybe, but I'm not sure what I could have put in it. Everything I knew was already in the conversation. I could rewrite "some rows are missing" five different ways, but it was still the same piece of information.&lt;/p&gt;

&lt;p&gt;What I actually needed wasn't a better way to describe the symptom. I needed one new fact about what the program was doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fact Was In The Order Of The Logs
&lt;/h2&gt;

&lt;p&gt;So I stopped asking for another fix, reverted the changes and went back to the worker as it was before all of this started. This time I followed a single import through the logs from beginning to end, including the database writes.&lt;/p&gt;

&lt;p&gt;That showed something off right away, just from the order of the lines. The worker logged "completed" before all of the inserts had finished. A job can't be done while its own writes are still running, unless nobody's waiting for them.&lt;/p&gt;

&lt;p&gt;Here's the shape of the code. On a quick read it looks fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Import completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;forEach&lt;/code&gt; calls the callback once per job and ignores whatever the callback returns. An async callback hands back a promise as soon as it reaches its first &lt;code&gt;await&lt;/code&gt;, so &lt;code&gt;forEach&lt;/code&gt; ends up with one promise per job, drops every one of them on the floor and returns as if the work were done. MDN says it plainly: &lt;code&gt;forEach()&lt;/code&gt; "expects a synchronous function" and "does not wait for promises." So the log line runs while the inserts are still in flight and the job gets marked complete with its writes still going in the background, and by the time some of those writes failed the worker had already reported the whole import as a success.&lt;/p&gt;

&lt;p&gt;I think that's also why a rerun kept bringing more rows in. Every run got a few more of the inserts through.&lt;/p&gt;

&lt;p&gt;The fix is to give the code something to wait on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;saveJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Import completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the log line waits for every insert and if one of them fails &lt;code&gt;Promise.all&lt;/code&gt; rejects and the code after it never gets to say "completed". It does start all the inserts at once, though, and MDN notes that a rejection "does not cancel the remaining operations", so after one failure the rest keep running. If you want the result of every insert, &lt;code&gt;Promise.allSettled&lt;/code&gt; waits for all of them. And if parallel inserts aren't wanted at all, a plain loop does one at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Import completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's slower, but nothing's running behind your back. (There's also a lint rule for this exact mistake. typescript-eslint's &lt;code&gt;no-misused-promises&lt;/code&gt; flags an async callback passed to &lt;code&gt;forEach&lt;/code&gt; and it's switched on in the &lt;code&gt;recommended-type-checked&lt;/code&gt; config, so a type-aware lint setup would have pointed straight at it.)&lt;/p&gt;

&lt;p&gt;Anyway, once I saw the order in the logs, finding the actual bug took a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  After Two Failed Fixes, I Go Find A Fact
&lt;/h2&gt;

&lt;p&gt;My rule now: if two AI fixes fail and I still haven't learned anything new about the bug, I stop asking for another fix. First I find one new fact: a repro, an error, a payload, a stack trace or just the real order of events like this time. Then the AI comes back in, with that fact. Not before.&lt;/p&gt;

&lt;p&gt;What made me stop this time was the diff, honestly. It had spread into the worker and the database code and the retries and the tests while the symptom stayed exactly where it was, and on top of that one of the new suggestions was just a variation of something it had already tried. Those two together are a pretty reliable sign that the guessing's run out. I'd add one more: a fix that swallows errors has stopped looking for the cause.&lt;/p&gt;

&lt;p&gt;I'm not saying AI is bad at debugging. I still use it for this all the time. But if I'm on the third fix and I still haven't learned anything new about the bug, I've probably stopped debugging and started guessing.&lt;/p&gt;

&lt;p&gt;That's what happened here. A fifth fix would have been working with the same information as the first four. The logs were the first thing that actually changed what I knew.&lt;/p&gt;

&lt;p&gt;How many failed fixes do you usually let an AI try before you stop and go back to investigating the bug yourself?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The AI Writes Better React than Vue</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:41:23 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/the-ai-writes-better-react-than-vue-5a9n</link>
      <guid>https://dev.to/nazar-boyko/the-ai-writes-better-react-than-vue-5a9n</guid>
      <description>&lt;p&gt;In May 2025 a team at ByteDance published Web-Bench, a coding benchmark with an unusual detail tucked into its appendix. It gives the same tasks to four UI frameworks (React, Vue, Angular and Svelte) and checks every result with end-to-end tests. The model that led their overall results was Claude 3.7 Sonnet. With thinking turned off it passed 65% of the tasks in React. In Vue it passed 30%.&lt;/p&gt;

&lt;p&gt;I also tried testing this on two projects, and sure enough, the AI is better trained or, I would say, has a better understanding of React projects. 🤷&lt;/p&gt;

&lt;p&gt;Here's the whole table from &lt;a href="https://arxiv.org/abs/2505.07473" rel="noopener noreferrer"&gt;the paper's appendix&lt;/a&gt;. The numbers are pass rates in percent, measured as pass@2 over five runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;React&lt;/th&gt;
&lt;th&gt;Vue&lt;/th&gt;
&lt;th&gt;Angular&lt;/th&gt;
&lt;th&gt;Svelte&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude 3.7 Sonnet (thinking)&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude 3.7 Sonnet&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doubao 1.5 Pro (thinking)&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doubao 1.5 Pro&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-R1&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's a small sample. Each framework gets one project of twenty tasks so five points is roughly one task. The ranking isn't clean either, and Svelte beat Vue for two of the six models even though far fewer people use it. But React came out at or above Vue for every model on the list. And the authors' first explanation is the one anyone would guess: React "provides the largest dataset for LLM training."&lt;/p&gt;

&lt;p&gt;It matches what I see in my own work. React from an assistant usually reads like React somebody wrote this just recently, whereas in Vue, deviations are more common! And these deviations often look as if someone used a Vue 2 template that no longer exists, or mixed two API styles within a single component. Nothing dramatic. Just more to check.&lt;/p&gt;

&lt;p&gt;So there's a criterion that has quietly slipped into framework choice, one nobody likes to say out loud: the AI is better at it. Is that a legitimate reason to pick a framework? Noooo... It's a legitimate input and a bad deciding factor. Here's how I get there. I'll start with the case for yes because it's stronger than the people who dislike the question want it to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model has just read a lot more React
&lt;/h2&gt;

&lt;p&gt;If we look at some basic statistics from GitHub, for example, we can see that the number of stars on GitHub hardly reflects this difference. The paper lists React at 235k and Vue at 208k but stars measure attention and a model learns from the code people actually write and publish, so usage is where the gap lives.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;2025 Stack Overflow survey&lt;/a&gt; 44.7% of respondents used React and 17.6% used Vue. On npm React got about 203 million downloads in the week of September 21 to 27, 2026 and Vue got about 18 million. That's the same roughly 11x gap that showed up when I &lt;a href="https://dev.to/nazar-boyko/react-and-vue-are-turning-into-the-same-framework-2aph"&gt;compared the two frameworks&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Vue also carries a history problem that React mostly doesn't. Vue 2 shipped in September 2016 and was supported until the last day of 2023, while Vue 3 only came out in September 2020 and removed a handful of things Vue 2 code leaned on (the &lt;code&gt;$on&lt;/code&gt; event methods, filters, &lt;code&gt;$set&lt;/code&gt;), so for four full years almost every new Vue tutorial, answer and public repo on the internet was written for Vue 2, and some of what they taught doesn't work anymore.&lt;/p&gt;

&lt;p&gt;Vue 3 also has two official ways to write a component. There's the Options API and there's the Composition API, and both are fully supported (the docs even point out that the Options API is built on top of the Composition API). So the model has three dialects of Vue in its training data. Mixing them is what I'd expect from a model that has seen all three used side by side for years. That's my reading, to be clear. The benchmark didn't test why.&lt;/p&gt;

&lt;p&gt;React has old dialects too. Class components are all over public code and the React team only deprecated Create React App in February 2025. The difference is proportion, I think. There's so much hooks-era React out there that the current idiom usually wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case for yes is real
&lt;/h2&gt;

&lt;p&gt;In the same survey the most common frustration with AI tools was "AI solutions that are almost right, but not quite" and 66% of developers picked it. And 45.2% said debugging AI-generated code takes longer. That's why if a project has only recently been updated to Vue 3 but some components or services still use the Vue 2 approach, then each such instance requires a code review. Each one costs a read and a re-prompt, and across a team and a year that adds up to real hours.&lt;/p&gt;

&lt;p&gt;Onboarding is the other half. Someone new to a React codebase who leans on an assistant gets taught current React. The same person on a Vue codebase can get taught a blend of Vue 2 and Vue 3 with no way to tell which parts are which. For a junior that can be worse than no help at all, because the old idiom arrives with exactly the same confidence as the current one and nothing in the diff or the passing test says which of the two they just learned.&lt;/p&gt;

&lt;p&gt;Honestly, the mixed-style component is the sneakier problem. It usually runs fine. It just makes every later reader's job harder and it's the kind of thing that gets merged because it works.&lt;/p&gt;

&lt;p&gt;So if a team is choosing between two frameworks it could live with equally well and it'll write most of its code with an assistant, I don't think it's silly to pick the one the assistant writes better, since that's a cost like any other and costs belong in a decision like this. If the argument stopped there I'd have nothing to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing by corpus feeds the corpus
&lt;/h2&gt;

&lt;p&gt;Here's where I get off. The first problem is that picking a framework because the model's read the most of it is a loop. The popular option gets more code written in it. The next models train on that code and get better at it, and that's what pushes more people toward it.&lt;/p&gt;

&lt;p&gt;Lovable's one of the AI app builders, and in February 2025 it explained why it generates React. The reason it gave is the loop in one sentence: models trained on code "can generate high-quality React code due to its dominance in online resources." So the apps a tool like that produces are React, and some of those apps get pushed to public repositories, and public repositories are exactly what the next round of models reads when its makers go looking for code to learn from, and round it goes. I don't blame Lovable, it's a sensible product call. That's exactly why it's a loop and not a conspiracy (every single step in it is reasonable on its own, and I'd probably have made the same call in their seat).&lt;/p&gt;

&lt;p&gt;There's a second gear, and it's the one that worries me more. The public Q&amp;amp;A that taught models how people really use a framework is drying up. A study in &lt;a href="https://academic.oup.com/pnasnexus/article/3/9/pgae400/7754871" rel="noopener noreferrer"&gt;PNAS Nexus&lt;/a&gt; found that activity on Stack Overflow dropped 25% within six months of ChatGPT's release, compared with Russian and Chinese Q&amp;amp;A sites where ChatGPT was hard to get. The authors' own conclusion is that "the decreased production of open data will limit the training of future models."&lt;/p&gt;

&lt;p&gt;React built its mountain of public answers before that happened. The next framework has to build its pile in a world where people ask a chatbot instead of posting a question, and the chatbot answers in a private window, and nobody's around to write the better answer three comments down that the next model would've learned from. If everyone picks by corpus size, nothing new ever gets a corpus.&lt;/p&gt;

&lt;p&gt;What I want is a Vue and a Svelte and whatever comes after them still pushing on React ten years from now, and a whole industry quietly picking by training data is how that stops happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models change in months, frameworks stay for years
&lt;/h2&gt;

&lt;p&gt;The second problem is timing. Look again at the model that led that benchmark. Its API name is &lt;code&gt;claude-3-7-sonnet-20250219&lt;/code&gt; and Anthropic's deprecation page lists it as retired on February 19, 2026. That's a year to the day after the date in its name. After that requests to it just fail.&lt;/p&gt;

&lt;p&gt;Vue 2 got support from September 2016 to December 31, 2023. More than seven years. A framework picked this year will very likely still be in the codebase long after the models that made it look like the easy choice have been deprecated, retired and replaced a few times over, by which point the reason it was picked is one line in an old planning doc and the code is the only thing left.&lt;/p&gt;

&lt;p&gt;The gap itself also moves, and the clearest sign of that comes from the React side. I use CLAUDE.md in my project, and it includes a block that &lt;code&gt;next dev&lt;/code&gt; wrote on his own!  And to be honest, I didn't even notice it! It opens with "This is NOT the Next.js you know" before warning the agent that APIs, conventions and file structure "may all differ from your training data" and telling it to read the docs bundled inside &lt;code&gt;node_modules&lt;/code&gt; before it writes any code at all. Turns out even React's biggest framework tells the model not to trust what it learned, and that's the side with the biggest corpus. Vercel measured the fix too. In their evals the agent that had the version-matched Next.js docs in front of it hit a 100% pass rate against 79% for the best skill-based setup.&lt;/p&gt;

&lt;p&gt;I'm not aware of a Vue measurement like that. There's a community project called vuejs-ai/skills, which calls itself an early experiment and lives outside the official Vue organization. Anyway, the point stands: a fluency gap is the kind of thing that can shrink in a release or two, and nobody revisits a framework choice that fast.&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%2Fc3i5aaxrzuledajeati9.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%2Fc3i5aaxrzuledajeati9.png" alt="Timeline from 2016 to 2027 comparing Vue 2 and Vue 3 support with three Claude model versions that were each retired 12 to 16 months after release" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  An input, not the decision
&lt;/h2&gt;

&lt;p&gt;So I'd keep it on the list, just not at the top. The codebase that already exists comes first, then the team you have and the one you'll hire, then the meta-framework that fits the product. After that comes how well the assistants you use write it, as a real line in the cost column. Between two options that tie on everything else it's a fair tiebreaker.&lt;/p&gt;

&lt;p&gt;And if the answer is Vue or Svelte or anything else with a smaller pile behind it, go in knowing the price. More review time per generated file. Output checked against the current docs, not the model's memory. Mixed API styles treated as a real review comment and not a nitpick. Putting the current docs in the model's context the way Next.js now does by default makes the gap smaller, though I'm not sure it ever gets to zero.&lt;/p&gt;

&lt;p&gt;I'd still rather pay that in review hours than pick a framework for the one reason that'll be out of date first. If you've picked a stack partly because the assistant was better at it, I'd like to hear how that held up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Enjoyed this one? Let's stay in touch — I'm on &lt;a href="https://www.linkedin.com/in/nazar-boyko" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, always happy to chat, swap ideas, or just say hi. 👋&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>react</category>
      <category>vue</category>
    </item>
    <item>
      <title>React and Vue Are Turning Into the Same Framework</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:26:49 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/react-and-vue-are-turning-into-the-same-framework-2aph</link>
      <guid>https://dev.to/nazar-boyko/react-and-vue-are-turning-into-the-same-framework-2aph</guid>
      <description>&lt;p&gt;Before diving into this article, I have to say that I haven't been a front-end developer for a very, very long time 😁 I just analyze architectures and enjoy listening to people who argue about what's best to use on the front end. So I might be wrong about some things. 🤷‍♂️🤷‍♂️🤷‍♂️ Please correct me if I'm wrong! &lt;/p&gt;

&lt;p&gt;Recently, I came across a team that was rewriting a project from Vue to Ract, and their argument was that Vue runs slower. 😬 This made me wonder, why does Vue run slower? And it turned out that the Vue code had been generated but not optimized. &lt;/p&gt;

&lt;p&gt;And while there were a lot of questions about the code generation itself in the Vue project, the team didn’t have any questions at all about the generated code in the React project because everything works well there. 🤔🤨 But I won’t go into that right now, I’d rather analyze everything in detail and describe it in another article.&lt;/p&gt;

&lt;p&gt;I won't go into a detailed comparison either, since there's plenty of that online. I'll just list the three main popular aspects below.  Some add a fourth about templates versus JSX. That one's taste, so I'll leave it alone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;React&lt;/th&gt;
&lt;th&gt;Vue&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Virtual DOM&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memoization&lt;/td&gt;
&lt;td&gt;by hand, with &lt;code&gt;memo&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;automatic, through reactivity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What re-renders&lt;/td&gt;
&lt;td&gt;the component and its children, unless you stop it&lt;/td&gt;
&lt;td&gt;only what read the state that changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row in that table is out of date or about to be. React now ships a compiler that writes the memoization for you, and Vue is finishing a compile mode that drops the virtual DOM for any component that opts in. The two of them started from opposite ends of the same old argument and ended up in roughly the same spot: a build step that works out what doesn't need to run.&lt;/p&gt;

&lt;p&gt;That's why I think this is the first year the interesting React vs Vue question isn't a technical one. They aren't the same framework. There's a whole section below on where they still split, but the three talking points everyone reaches for are exactly the three that are dissolving.&lt;/p&gt;

&lt;h2&gt;
  
  
  React's Memoization Moved Into The Build
&lt;/h2&gt;

&lt;p&gt;React's default is simple and a bit blunt. The &lt;code&gt;memo&lt;/code&gt; docs say it in one line: "React normally re-renders a component whenever its parent re-renders." For years the fix was to tell React by hand what was safe to skip. Here's what that looks like in a small product list with a search box and rows you can pick:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProductList.jsx (by hand, before the compiler)&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ProductRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductRow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onPick&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onPick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPicked&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onPick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPicked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Picked: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductRow&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onPick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onPick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&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;There are three tools here and two of them only work as a pair. &lt;code&gt;memo&lt;/code&gt; lets a row skip rendering when its props look the same as last time. &lt;code&gt;useCallback&lt;/code&gt; keeps &lt;code&gt;onPick&lt;/code&gt; the same function between renders. That part matters. A fresh function counts as a changed prop and quietly defeats the &lt;code&gt;memo&lt;/code&gt;. Forget the &lt;code&gt;useCallback&lt;/code&gt; and the &lt;code&gt;memo&lt;/code&gt; still compiles, still passes review, still looks perfectly responsible in the diff. It just skips nothing. &lt;code&gt;useMemo&lt;/code&gt; is the odd one out. It keeps the filter from running again when the only thing that changed is &lt;code&gt;picked&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The React team was blunter about all this than I'd be, and in the React Labs post from February 2024 they wrote that "manual memoization is a compromise. It clutters up our code, is easy to get wrong, and requires extra work to keep up to date."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://react.dev/blog/2025/10/07/react-compiler-1" rel="noopener noreferrer"&gt;React Compiler 1.0&lt;/a&gt; shipped in October 2025 and works with React 17 and up. For new code the release post recommends "relying on the compiler for memoization and using useMemo/useCallback where needed to achieve precise control." Expo has had it on by default since SDK 54. Next.js 16 made the &lt;code&gt;reactCompiler&lt;/code&gt; option stable but left it off by default while the team gathers build performance data (they also warn that builds get slower with it on, since the compiler runs through Babel, and the Rust port that runs inside Turbopack only showed up in Next.js 16.3 in August 2026 as an experimental flag). So "React memoizes for you now" is true, with a footnote: on the most popular React framework, somebody still has to flip a switch first.&lt;/p&gt;

&lt;p&gt;What matters for this comparison is the component with the compiler on, next to the Vue version of the same thing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProductList.jsx (with React Compiler)&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductRow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onPick&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onPick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPicked&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onPick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPicked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Picked: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductRow&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onPick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onPick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&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;&lt;strong&gt;&lt;code&gt;ProductList.vue&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ProductRow&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./ProductRow.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineProps&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"query"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Picked: &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ul&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ProductRow&lt;/span&gt;
      &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"p in visible"&lt;/span&gt;
      &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"p.id"&lt;/span&gt;
      &lt;span class="na"&gt;:product=&lt;/span&gt;&lt;span class="s"&gt;"p"&lt;/span&gt;
      &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;pick=&lt;/span&gt;&lt;span class="s"&gt;"picked = p.id"&lt;/span&gt;
    &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The React version's just the first one with every wrapper deleted. The Vue version never had wrappers to delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compiler Writes Your useCallback, Not Always Your useMemo
&lt;/h2&gt;

&lt;p&gt;I ran that plain React file through &lt;code&gt;babel-plugin-react-compiler&lt;/code&gt; 1.0.0, because I wanted to see what it actually produces rather than trust the diagrams, and the answer is a lot of bookkeeping: &lt;code&gt;ProductList&lt;/code&gt; gets 21 cache slots, &lt;code&gt;ProductRow&lt;/code&gt; gets 6 of its own, and every one of them is a plain array entry checked by an &lt;code&gt;if&lt;/code&gt;. Here's the part of &lt;code&gt;ProductList&lt;/code&gt; that matters, trimmed down:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProductList.jsx after babel-plugin-react-compiler 1.0.0 (trimmed)&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;products&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPicked&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;picked&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react.memo_cache_sentinel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;t5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPicked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;t5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onPick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Symbol.for("react.memo_cache_sentinel")&lt;/code&gt; check is the compiler's version of &lt;code&gt;useCallback(fn, [])&lt;/code&gt;. The first render stores the function in slot 8 and every render after that reads it back. Nobody wrote a dependency array.&lt;/p&gt;

&lt;p&gt;The filter's where it gets interesting. My hand-written &lt;code&gt;useMemo&lt;/code&gt; was keyed on &lt;code&gt;products&lt;/code&gt; and &lt;code&gt;query&lt;/code&gt;. The compiler put &lt;code&gt;products.filter(...)&lt;/code&gt; inside a block that's also keyed on &lt;code&gt;picked&lt;/code&gt;. So picking a row runs the filter again. With fifty products nobody will ever notice. If the filter were genuinely slow, this is the case the release post means when it says to keep &lt;code&gt;useMemo&lt;/code&gt; for "precise control", because the compiler caches what flows into the output and its idea of a sensible cache boundary (one big block keyed on everything the JSX reads, as far as I can tell from this output) isn't always the one I'd have drawn by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vue Got The Same Result From Reactivity
&lt;/h2&gt;

&lt;p&gt;None of this was ever a question in the Vue version. The reason is how Vue decides what to run. Its docs describe it plainly: "each component instance creates a reactive effect to render and update the DOM." The render reads &lt;code&gt;query&lt;/code&gt;, &lt;code&gt;picked&lt;/code&gt; and &lt;code&gt;visible&lt;/code&gt;, Vue records those reads, and only a change to one of them re-runs this render. &lt;code&gt;computed&lt;/code&gt; caches its value and only re-runs the filter when &lt;code&gt;products&lt;/code&gt; or &lt;code&gt;query&lt;/code&gt; changes, so picking a row doesn't re-filter and I never had to say so.&lt;/p&gt;

&lt;p&gt;Children follow a matching rule. Vue's &lt;a href="https://vuejs.org/guide/best-practices/performance.html" rel="noopener noreferrer"&gt;performance guide&lt;/a&gt; says "a child component only updates when at least one of its received props has changed."&lt;/p&gt;

&lt;p&gt;Here's the detail I didn't know until I read the compiled output. In the normal virtual DOM build the inline &lt;code&gt;@pick&lt;/code&gt; handler inside the &lt;code&gt;v-for&lt;/code&gt; becomes a brand-new function on every render. That's exactly what &lt;code&gt;useCallback&lt;/code&gt; exists to prevent in React. Vue doesn't care. The runtime function that decides whether a child updates is called &lt;code&gt;shouldUpdateComponent&lt;/code&gt;, and in it a changed prop only counts if it isn't a declared event listener, so as long as &lt;code&gt;ProductRow&lt;/code&gt; declares &lt;code&gt;pick&lt;/code&gt; in &lt;code&gt;defineEmits&lt;/code&gt;, a brand-new handler on every render doesn't make a single row update. React asked me to keep the function stable. Vue just stopped comparing it.&lt;/p&gt;

&lt;p&gt;Vue isn't completely free either. "Never needed memoization" oversells it a little. The same performance guide has a section on props stability: pass &lt;code&gt;activeId&lt;/code&gt; to every item in a list and every item updates when it changes, pass &lt;code&gt;active&lt;/code&gt; (a boolean worked out in the parent) and only the items whose value actually flipped do. That's the same class of problem React's &lt;code&gt;memo&lt;/code&gt; solves, except in Vue you fix it by shaping props instead of wrapping components.&lt;/p&gt;

&lt;p&gt;Vue even has its own dependency array. &lt;code&gt;v-memo&lt;/code&gt;, which arrived in 3.2, skips a whole subtree unless the values in its array change. The docs say it's "provided solely for micro optimizations in performance-critical scenarios and should be rarely needed." Rarely needed is the honest version of never.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vue Is Compiling Its Virtual DOM Away
&lt;/h2&gt;

&lt;p&gt;Vue's virtual DOM already isn't the purely runtime kind. The template compiler leaves hints for the runtime (patch flags that say which part of a node can change, cached static content, flattened blocks so the diff skips whatever can't change), and the Vue docs call this a "Compiler-Informed Virtual DOM" and contrast it with React's, where "the reconciliation algorithm cannot make any assumptions about the incoming virtual DOM tree."&lt;/p&gt;

&lt;p&gt;Vapor Mode goes the rest of the way. A component that opts in compiles to direct DOM operations with no virtual DOM at all, and since this is the claim in here that's most likely to go stale, here's exactly where it stands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vapor first shipped in Vue 3.6.0-alpha.1 in July 2025.&lt;/li&gt;
&lt;li&gt;The first beta landed in December 2025, with the team saying Vapor now had "feature parity with all stable features in Virtual DOM mode" (Suspense in a Vapor-only app being the exception).&lt;/li&gt;
&lt;li&gt;3.6 went to release candidate in July 2026. The newest one as I write this is 3.6.0-rc.9, from September 18.&lt;/li&gt;
&lt;li&gt;npm's &lt;code&gt;latest&lt;/code&gt; tag for &lt;code&gt;vue&lt;/code&gt; still points at 3.5.43. There's no stable Vue release with Vapor in it yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/vuejs/core/releases/tag/v3.6.0-rc.1" rel="noopener noreferrer"&gt;RC release notes&lt;/a&gt; call Vapor "feature-complete" and "100% opt-in." For now they recommend it for two things: a performance-sensitive page inside an existing app or a small new app built entirely in Vapor. Opting in takes one attribute on the script tag, &lt;code&gt;&amp;lt;script setup vapor&amp;gt;&lt;/code&gt;, and after that the only real decision is about the app: one created with &lt;code&gt;createVaporApp()&lt;/code&gt; leaves the virtual DOM runtime out of the bundle entirely, while a normal &lt;code&gt;createApp()&lt;/code&gt; app can mix Vapor components in through &lt;code&gt;vaporInteropPlugin&lt;/code&gt; and keep everything else exactly as it was.&lt;/p&gt;

&lt;p&gt;I compiled the Vue component from the tabs above with 3.6.0-rc.9 in both modes. The normal build returns a render function that builds vnodes, like Vue always has, while the Vapor build returns this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProductList.vue with script setup vapor, Vue 3.6.0-rc.9 (trimmed)&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;input&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;p&amp;gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;ul&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_txt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;_renderEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;_setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Picked: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;_toDisplayString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_createFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_for_item0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_on_pick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_for_item0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_createComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ProductRow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_for_item0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;onPick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;_on_pick&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n4&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="cm"&gt;/* FAST_REMOVE, IS_COMPONENT */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no render function here and no vnodes. The markup is turned into real DOM templates once and cloned from then on, the &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; gets exactly one &lt;code&gt;_renderEffect&lt;/code&gt;, and when a row is picked that effect is the only thing in the whole component that runs: it sets the text of one text node, and that's it. Props go to the child as getter functions (&lt;code&gt;product: () =&amp;gt; (_for_item0.value)&lt;/code&gt;), so the child reads the current value itself instead of receiving a new props object. The component's setup runs once and never again. Turns out that's the Solid model, and Vue's own docs say so, describing Vapor as a "Solid-inspired compilation strategy."&lt;/p&gt;

&lt;p&gt;The subset's real, though. Vapor components don't support the Options API, &lt;code&gt;app.config.globalProperties&lt;/code&gt; or &lt;code&gt;v-memo&lt;/code&gt;, and &lt;code&gt;getCurrentInstance()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; inside them, but the one I'd watch for in an existing app is events. Vapor delegates most listeners to &lt;code&gt;document&lt;/code&gt;. The release notes spell out the catch: if any ancestor calls &lt;code&gt;stopPropagation()&lt;/code&gt; "the delegated handler will not run." Mixing Vapor and virtual DOM components works for ordinary props, events and slots, but the notes still warn about "rough edges" with VDOM-based component libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Paths Met At The Compiler, Not At The Mental Model
&lt;/h2&gt;

&lt;p&gt;Put the two compiled outputs side by side. The convergence is obvious. Both frameworks now ship a compiler that rewrites a component into code nobody would write by hand, and both compilers exist for the same reason: to skip work for things that didn't change.&lt;/p&gt;

&lt;p&gt;The mechanism's where they still split, and honestly I think it's the most important difference left. React's compiled &lt;code&gt;ProductList&lt;/code&gt; is still a function that runs on every single render and checks its 21 cache slots on the way through to reuse whatever it can, while Vapor's version runs once and then leaves individual DOM nodes to a handful of effects, so that component never renders a second time.&lt;/p&gt;

&lt;p&gt;React chose that on purpose, and the same React Labs post from 2024 says so: "Our vision is for React to automatically re-render just the right parts of the UI when state changes, without compromising on React's core mental model." That core model is UI as a function of state, and signals would change it. The &lt;a href="https://github.com/tc39/proposal-signals" rel="noopener noreferrer"&gt;TC39 Signals proposal&lt;/a&gt; is still at Stage 1. Its README credits design input from the authors of thirteen named projects, Vue among them. React isn't one of them. Vue's docs say outright that "signals are the same kind of reactivity primitive as Vue refs." And 3.6 goes further: it rewrites the core of &lt;code&gt;@vue/reactivity&lt;/code&gt; by porting alien-signals, a standalone signals library, for better speed and memory use, so a reactivity system that was always signal-shaped now literally has a port of one sitting at its center. (The state management landscape piece covers what signals look like when you add them to React from the outside.)&lt;/p&gt;

&lt;p&gt;This one's the difference you feel every day, not just in benchmarks. In React I call &lt;code&gt;setPicked(id)&lt;/code&gt; and the whole function runs again with new values and the props stay read-only snapshots the entire time, so what I reason about is renders, while in Vue I write &lt;code&gt;picked.value = p.id&lt;/code&gt; and the state changes in place and whatever read it updates on its own. There I reason about dependencies. Someone fluent in one has to relearn habits in the other. No compiler touches that part.&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%2Froaxu9ys9lqx5ryiu0zm.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%2Froaxu9ys9lqx5ryiu0zm.png" alt="Diagram Two Paths, One Compiler: React goes from UI as a function of state through manual memoization, Vue from reactive refs through a compiler-informed virtual DOM, both meet at a compiler that decides what to skip, then split into React Compiler 1.0 re-running the function and Vapor Mode running setup once" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Server Moved Closer On Both Sides, Unevenly
&lt;/h2&gt;

&lt;p&gt;The third shared direction is the server. It's the messiest one. React 19 made Server Components stable in December 2024, and in the Next.js App Router "layouts and pages are Server Components" by default, with &lt;code&gt;'use client'&lt;/code&gt; added only where a component needs state or event handlers. And there's nothing like that in Vue's core. Nuxt has server components: &lt;code&gt;.server.vue&lt;/code&gt; files that render on the server as islands and add none of their code to the client bundle, but the &lt;a href="https://nuxt.com/docs/4.x/guide/concepts/server-components" rel="noopener noreferrer"&gt;Nuxt docs&lt;/a&gt; say they're "still marked experimental" and that on client-side navigation "each island on the destination page must be fetched from the server." That's a different shape from RSC, where server components are the default and a serialized payload carries the rendered server tree to the client. Nuxt's stronger server story is Nitro, the server engine that lets API routes live in the same project as the pages, and it's the part of Nuxt I'd point a React developer at first.&lt;/p&gt;

&lt;p&gt;Moving work to the server isn't free on either side, and the Next.js 16.3 announcement says plainly that Server Components "made navigations feel slow." A big chunk of that release is tooling to fix it. So both camps agree the server should do more, they just don't agree on the unit: React made the component the unit of server work, while Nuxt works at the level of routes and server endpoints and keeps component islands experimental.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ecosystems Are Nowhere Near The Same Size
&lt;/h2&gt;

&lt;p&gt;Here's the row that didn't converge at all. These are npm downloads for the week of September 20 to 26, 2026, pulled straight from the npm downloads API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Weekly downloads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;react&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;203,498,378&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;18,457,765&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;67,256,843&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nuxt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,364,072&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Download counts measure installs, not people (every CI run counts, and so does every package that depends on these), but an 11x gap between the libraries and a 28x gap between the meta-frameworks isn't noise, and Stack Overflow's 2025 survey points the same way from the people side: 44.7% of respondents used React and 17.6% used Vue, 20.8% used Next.js and 4% used Nuxt.&lt;/p&gt;

&lt;p&gt;One number from that same week made me look twice. &lt;code&gt;babel-plugin-react-compiler&lt;/code&gt; got 17.8 million downloads, nearly as many as &lt;code&gt;vue&lt;/code&gt; itself. That's not a typo. A big part of that is Expo. Its Babel preset depends on the plugin because Expo turns the compiler on by default, and &lt;code&gt;babel-preset-expo&lt;/code&gt; alone got 10.9 million downloads in the same week. Still. A React build plugin now gets installed about as often as the entire Vue framework.&lt;/p&gt;

&lt;p&gt;None of that makes Vue small. Eighteen million installs a week is a very alive framework. But size is what decides how easy hiring is and how many libraries work on day one and how many answers already exist for your strange edge case, and React has a lot more of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd Actually Make The Call
&lt;/h2&gt;

&lt;p&gt;Okay, but Vapor benchmarks alongside Solid and Svelte 5. Isn't that exactly the kind of performance gap a React compiler can't close?&lt;/p&gt;

&lt;p&gt;It's a real gap in the benchmark. I won't argue that. The Vue team cites the js-framework-benchmark for that claim, and a compiled React component that still re-runs on every render isn't going to match one that never re-runs. It just doesn't decide the choice for most products. Vapor is still a release candidate, it's opt-in per component, it supports a subset of Vue, and the Vue team itself recommends it for one hot page or a small new app. And in my view the diff is rarely what makes a product feel slow anyway (the network and the amount of JavaScript shipped usually are), so if one screen really is diff-bound that's a reason to try Vapor on that one screen and not a reason to pick a whole framework.&lt;/p&gt;

&lt;p&gt;So here's how I'd choose, in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The codebase you already have.&lt;/strong&gt; If there's a working React or Vue app, keep it. In 2026 a rewrite from one to the other buys almost nothing on performance. Turning on the React Compiler is far cheaper than a migration. So is trying Vapor on the one slow page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The team you have and the one you'll hire.&lt;/strong&gt; A team that knows Vue deeply will ship better Vue than it'd ship average React, and the reverse holds too. When people switch, it's the mental model from earlier that costs them. The APIs are the easy part. If you're hiring a lot, the survey numbers are the honest tiebreaker: the React pool is about two and a half times bigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The meta-framework that fits the product.&lt;/strong&gt; This is the one I'd spend the most time on. It now decides more of the architecture than the UI library does. Next.js if the product wants server components as the default and the team is ready for the server and client boundary and its caching model. Nuxt if the product wants a full-stack Vue app with server routes next to the pages and a more gradual path to the server. Most of the differences between React and Vue that still matter in 2026 live one layer up, in these two.&lt;/p&gt;

&lt;p&gt;If your team already knows one of them well, that's your answer. It probably always was, we just had more interesting things to argue about.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Enjoyed this one? Let's stay in touch — I'm on &lt;a href="https://www.linkedin.com/in/nazar-boyko" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, always happy to chat, swap ideas, or just say hi. 👋&lt;/em&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>react</category>
      <category>vue</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Laravel Is Not As Heavy As You Think</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:25:12 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/laravel-is-not-as-heavy-as-you-think-571h</link>
      <guid>https://dev.to/nazar-boyko/laravel-is-not-as-heavy-as-you-think-571h</guid>
      <description>&lt;p&gt;Recently I came across another discussion about Laravel's memory usage and I can't say that it was exactly new to me, I've worked with Laravel and Lumen for years and have seen how differently they can behave in terms of memory, but it made me curious about what actually creates that difference. Is Laravel itself really that expensive, is Lumen significantly lighter because of the framework, or are we mostly looking at the effects of PHP configuration, OPcache, bootstrapping, and the way the application loads data? 🤷‍♂️&lt;/p&gt;

&lt;p&gt;So I decided to measure it properly and break down where the memory actually goes. The result was not what I expected.&lt;/p&gt;

&lt;p&gt;A hello-world route in a fresh Laravel 13 app peaks approximately at 0.66MB of PHP memory. The framework's share of that is 316KB. That's everything that runs before my controller method does. Ten thousand Eloquent models loaded in the same app cost 17MB. I went in expecting the ratio to go the other way because the number everyone quotes for a Laravel request is "20MB, maybe 30" and the framework gets the blame for it.&lt;/p&gt;

&lt;p&gt;And it turns out that this number is also accurate! It’s simply the value you get when OPcache is disabled, and OPcache is off in exactly the places people measure, which is &lt;code&gt;php artisan tinker&lt;/code&gt; and the test suite and the dev container, so the number travels from there into the &lt;code&gt;pm.max_children&lt;/code&gt; calculation and the &lt;code&gt;memory_limit&lt;/code&gt; argument and the "Laravel is heavy" thread, and nobody checks it again. So this piece is the two numbers side by side, then a walk through the knobs everyone reaches for with a before and after for each (&lt;code&gt;php artisan optimize&lt;/code&gt;, the Composer class map, deferred providers, OPcache itself), then the one thing that actually moved the number by a lot. Which was how the models got loaded.&lt;/p&gt;

&lt;p&gt;The setup first so the numbers mean something. Laravel 13.31.0 on PHP 8.4.22, in the official &lt;code&gt;php:8.4-fpm-alpine&lt;/code&gt; image on an Apple silicon Mac, with MySQL 8.4.11 in a second container, and every request going through real PHP-FPM, a static pool with a single worker, with &lt;code&gt;cgi-fcgi&lt;/code&gt; sending the requests so nothing else touches the process. Every number below is the third request to a route, so the worker's warm and OPcache has compiled everything it's going to compile. And &lt;code&gt;MB&lt;/code&gt; in this article means 1,048,576 bytes because that's what &lt;code&gt;memory_limit&lt;/code&gt; counts in.&lt;/p&gt;

&lt;p&gt;The measuring code is small. Two constants at the top of &lt;code&gt;public/index.php&lt;/code&gt; before the autoloader loads:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;public/index.php&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LARAVEL_START'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MEM_INDEX_START'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MEM_INDEX_START_REAL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a controller method that takes a reading on entry and does the route's work and returns every counter I could think of as JSON:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;app/Http/Controllers/MeasureController.php&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;modelsGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;='&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'index_start'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MEM_INDEX_START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'route_entry'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'after_hydrate'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'peak'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'peak_real'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'count'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(),&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;The worker's resident memory comes from reading &lt;code&gt;VmRSS&lt;/code&gt; out of &lt;code&gt;/proc/self/status&lt;/code&gt; in the same method, which I left out of the listing. The &lt;code&gt;items&lt;/code&gt; table has 100,000 rows. Its eight columns are the kind a product table has: a SKU, a name, a 120-character description, a price, a quantity, a flag and the two timestamps. It's boring on purpose, because I wanted the shape of a product table rather than a benchmark table.&lt;/p&gt;

&lt;h2&gt;
  
  
  memory_get_usage(true) Counts 2MB Chunks, And A Warm Worker Starts With Several
&lt;/h2&gt;

&lt;p&gt;Before any Laravel numbers I want the two functions straight. The gap between them confuses people and honestly I had to go read the allocator to be sure I understood it myself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;memory_get_usage()&lt;/code&gt; is what the script has allocated right now through PHP's allocator. &lt;code&gt;memory_get_usage(true)&lt;/code&gt; is what that allocator has taken from the operating system whether the script is using it or not. The manual says the second one is "the value that memory_limit is enforced against" and adds that the amount the operating system has given the process is a different and typically much larger number. So there are three numbers, what you use, what the allocator holds and what the process holds, and this article is mostly about the first with a stop at each of the others.&lt;/p&gt;

&lt;p&gt;The allocator is the Zend Memory Manager (&lt;code&gt;Zend/zend_alloc.c&lt;/code&gt; in php-src). It asks the OS for memory in chunks of exactly &lt;code&gt;2 * 1024 * 1024&lt;/code&gt; bytes, the &lt;code&gt;ZEND_MM_CHUNK_SIZE&lt;/code&gt; constant in &lt;code&gt;zend_alloc_sizes.h&lt;/code&gt;, and it hands 4KB pages out of those chunks to the script. That's why &lt;code&gt;memory_get_usage(true)&lt;/code&gt; only ever returns multiples of &lt;code&gt;2,097,152&lt;/code&gt;. Never anything else. Every "real" figure in my results is one of those: &lt;code&gt;2,097,152&lt;/code&gt; for the hello route and &lt;code&gt;20,971,520&lt;/code&gt; for 10,000 models and &lt;code&gt;186,654,720&lt;/code&gt; for 100,000. Anything bigger than a chunk minus one page is a "huge" allocation and gets its own &lt;code&gt;mmap()&lt;/code&gt; call. So the very large numbers aren't rounded quite as coarsely.&lt;/p&gt;

&lt;p&gt;Here's the part I didn't know. On the third request for 10,000 models the worker had just served the first two. &lt;code&gt;memory_get_usage(true)&lt;/code&gt; at the very top of &lt;code&gt;index.php&lt;/code&gt; said &lt;code&gt;14,680,064&lt;/code&gt;. Seven chunks before a single line of Laravel ran. After two requests of 100,000 models the same line said &lt;code&gt;136,314,880&lt;/code&gt;. That's sixty-five chunks. The allocator doesn't hand chunks back at the end of a request. In &lt;code&gt;zend_mm_shutdown&lt;/code&gt; it keeps a running average of how many chunks each request peaked at, holds that many in a cache for the next one, and resets the real counter to &lt;code&gt;(cached_chunks_count + 1) * ZEND_MM_CHUNK_SIZE&lt;/code&gt;, so on a worker that has seen a heavy request the real number starts high and stays high for a while, even for a hello world, until enough small requests pull the average back down. That's not a leak, it's the allocator guessing that the next request will look like the last few.&lt;/p&gt;

&lt;p&gt;Two practical consequences. &lt;code&gt;memory_limit&lt;/code&gt; is enforced against the chunk count. A script whose &lt;code&gt;memory_get_usage()&lt;/code&gt; never reached 128MB can still die with "Allowed memory size of 134217728 bytes exhausted", because the chunks behind it got there first. And if you log &lt;code&gt;memory_get_peak_usage(true)&lt;/code&gt; from a long-lived worker as the cost of a request you're partly logging the previous request. For per-request work the non-real peak is the honest one. Since PHP 8.2 there's also &lt;code&gt;memory_reset_peak_usage()&lt;/code&gt; for the Octane and queue-worker case where the process never ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without OPcache The Bootstrap Is 15MB. With It, 316KB
&lt;/h2&gt;

&lt;p&gt;Now Laravel. Same hello route and same worker in two configurations, and that's the whole experiment. The first is what &lt;code&gt;laravel new&lt;/code&gt; leaves you with when OPcache isn't on: no config cache and no route cache and the plain PSR-4 autoloader. The second is what a deploy script should leave you with: &lt;code&gt;php artisan optimize&lt;/code&gt; plus an optimized class map plus OPcache on.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;th&gt;OPcache off, no caches&lt;/th&gt;
&lt;th&gt;OPcache on, caches on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;top of index.php&lt;/td&gt;
&lt;td&gt;385,200&lt;/td&gt;
&lt;td&gt;339,640&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after vendor/autoload.php&lt;/td&gt;
&lt;td&gt;2,007,888&lt;/td&gt;
&lt;td&gt;360,608&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;after bootstrap/app.php&lt;/td&gt;
&lt;td&gt;3,285,760&lt;/td&gt;
&lt;td&gt;418,152&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;controller method entry&lt;/td&gt;
&lt;td&gt;15,773,808&lt;/td&gt;
&lt;td&gt;663,096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;peak for the whole request&lt;/td&gt;
&lt;td&gt;17,827,888&lt;/td&gt;
&lt;td&gt;691,216&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;files included&lt;/td&gt;
&lt;td&gt;460&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classes declared&lt;/td&gt;
&lt;td&gt;444&lt;/td&gt;
&lt;td&gt;410&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wall time&lt;/td&gt;
&lt;td&gt;28.3ms&lt;/td&gt;
&lt;td&gt;0.8ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bootstrap here means the difference between the top of &lt;code&gt;index.php&lt;/code&gt; and the first line of the controller, and it's &lt;code&gt;15,388,608&lt;/code&gt; bytes in the first column and &lt;code&gt;323,456&lt;/code&gt; in the second. 14.7MB against 316KB for the same framework and the same 400-odd files and the same providers registered and booted. The 47x difference is where the compiled code lives.&lt;/p&gt;

&lt;p&gt;This was one of those results where I reran the test a few times because the difference looked almost too large to be real. 😁😁😁&lt;/p&gt;

&lt;p&gt;Without OPcache every one of those 460 files is read, tokenized, parsed and compiled on every request, and the result (the opcodes, the class tables, the constant arrays, the interned strings) is allocated in the request's own heap through the same allocator &lt;code&gt;memory_get_usage()&lt;/code&gt; watches, and that's the 15MB. Laravel isn't doing anything with it. It's the cost of holding the compiled form of Laravel in memory once per request per worker and throwing it away at the end.&lt;/p&gt;

&lt;p&gt;With OPcache on the compiled form lives in a shared memory segment. PHP-FPM's master process maps it once and every worker uses it. &lt;code&gt;opcache_get_status()&lt;/code&gt; on the same hello request reported 412 cached scripts using &lt;code&gt;22,686,344&lt;/code&gt; bytes of that segment out of the default 128MB (&lt;code&gt;opcache.memory_consumption=128&lt;/code&gt;). So the 15MB didn't disappear, it became 21.6MB that's paid once per server instead of once per request, and it stopped showing up in &lt;code&gt;memory_get_usage()&lt;/code&gt; entirely because OPcache's segment isn't the request heap.&lt;/p&gt;

&lt;p&gt;An honesty note, since this is the part where a cheaper per-request number gets sold as a cheaper server. The FPM worker's resident memory for the hello route was 27.7MB with OPcache off and 30.7MB with it on. That's higher, not lower. The worker now has the shared segment's touched pages mapped in as well as its own heap. What OPcache buys isn't a smaller process. It's a process that doesn't grow by 15MB of compiled framework every time a request starts, and a request that takes 0.8ms instead of 28.&lt;/p&gt;

&lt;h2&gt;
  
  
  'php artisan optimize' And The Class Map Barely Move The Number
&lt;/h2&gt;

&lt;p&gt;This is the section I expected to be the meat of the article, and it turned out to be the footnote. Every deploy guide says to run &lt;code&gt;php artisan optimize&lt;/code&gt; (&lt;code&gt;config:cache&lt;/code&gt; and &lt;code&gt;event:cache&lt;/code&gt; and &lt;code&gt;route:cache&lt;/code&gt; and &lt;code&gt;view:cache&lt;/code&gt; in one command) and &lt;code&gt;composer install --optimize-autoloader&lt;/code&gt;. Both are right. Neither's about memory.&lt;/p&gt;

&lt;p&gt;Peak memory of the hello route in all eight combinations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Autoloader&lt;/th&gt;
&lt;th&gt;artisan optimize&lt;/th&gt;
&lt;th&gt;OPcache&lt;/th&gt;
&lt;th&gt;Entry&lt;/th&gt;
&lt;th&gt;Peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PSR-4&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;15.04MB&lt;/td&gt;
&lt;td&gt;17.00MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PSR-4&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;14.33MB&lt;/td&gt;
&lt;td&gt;16.40MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;class map&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;15.53MB&lt;/td&gt;
&lt;td&gt;17.60MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;authoritative class map&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;15.53MB&lt;/td&gt;
&lt;td&gt;17.60MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PSR-4&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;0.77MB&lt;/td&gt;
&lt;td&gt;0.80MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;class map&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;0.77MB&lt;/td&gt;
&lt;td&gt;0.80MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PSR-4&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;0.63MB&lt;/td&gt;
&lt;td&gt;0.66MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;class map&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;0.63MB&lt;/td&gt;
&lt;td&gt;0.66MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;php artisan optimize&lt;/code&gt; is worth 0.7MB per request without OPcache and 143KB with it, and that's the whole effect. You can see the mechanism in the file count: 460 included files without the caches and 400 with them. The config cache replaces reading &lt;code&gt;.env&lt;/code&gt; plus every file in &lt;code&gt;config/&lt;/code&gt; with one &lt;code&gt;require&lt;/code&gt; of a 20KB &lt;code&gt;bootstrap/cache/config.php&lt;/code&gt;. The route cache replaces &lt;code&gt;routes/web.php&lt;/code&gt; and all the registration calls with a single cached file. Those are real savings in time and syscalls (the docs describe &lt;code&gt;config:cache&lt;/code&gt; as reducing "the number of trips the framework must make to the filesystem"), and they're tiny in memory, because reading thirty small PHP files and reading one bigger one produce roughly the same arrays in the end.&lt;/p&gt;

&lt;p&gt;The class map is the more interesting row because it goes the wrong way. &lt;code&gt;composer dump-autoload -o&lt;/code&gt; turns the PSR-4 rules into a flat array of 6,861 class-to-file entries (this app has 8,090 PHP files under &lt;code&gt;vendor/&lt;/code&gt;) and that array has to exist in memory to be useful. Without OPcache it's built on every request, the "after autoload" checkpoint went from &lt;code&gt;2,007,888&lt;/code&gt; bytes to &lt;code&gt;3,272,056&lt;/code&gt;, and the whole request got 1.2MB more expensive. With OPcache the array is a literal in a compiled file and OPcache stores literal arrays immutably in the shared segment and the per-request cost of the map is zero. The &lt;code&gt;--classmap-authoritative&lt;/code&gt; flag, the one that stops Composer from checking the filesystem for classes missing from the map, changed nothing I could measure either way. Which is fine, because Composer's own docs sell the class map as a speed feature ("should always be enabled in production") and speed is what it delivers: fewer &lt;code&gt;file_exists&lt;/code&gt; calls per class rather than fewer bytes.&lt;/p&gt;

&lt;p&gt;So both knobs are worth turning and both are for CPU and disk rather than memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deferred Providers Save Nothing Unless register() Does Work
&lt;/h2&gt;

&lt;p&gt;Deferred providers come up in every Laravel performance thread so I tested them too. The framework already does this for itself: the compiled manifest in &lt;code&gt;bootstrap/cache/services.php&lt;/code&gt; for this app lists 31 providers (30 from the framework plus &lt;code&gt;AppServiceProvider&lt;/code&gt;), and 16 of them are eager and 15 are deferred. The question was what deferring my own provider would save.&lt;/p&gt;

&lt;p&gt;Four providers added one at a time to &lt;code&gt;bootstrap/providers.php&lt;/code&gt;, measured on the hello route with everything else in the production configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A normal provider&lt;/strong&gt;: three &lt;code&gt;singleton()&lt;/code&gt; bindings in &lt;code&gt;register()&lt;/code&gt;, nothing in &lt;code&gt;boot()&lt;/code&gt;. The kind you write for a payment gateway or a search client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same provider, deferred&lt;/strong&gt;: &lt;code&gt;implements DeferrableProvider&lt;/code&gt;, with &lt;code&gt;provides()&lt;/code&gt; listing the three bindings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A heavy provider&lt;/strong&gt;: &lt;code&gt;register()&lt;/code&gt; does &lt;code&gt;require&lt;/code&gt; on a 527KB PHP file that returns a 5,000-entry lookup array and binds it as an instance. The anti-pattern where "registering" a service means building it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same heavy provider, deferred&lt;/strong&gt;: the &lt;code&gt;require&lt;/code&gt; moves inside the singleton closure, so it runs only when something resolves the service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extra bytes at controller entry compared with no extra provider at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;OPcache off&lt;/th&gt;
&lt;th&gt;OPcache on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;normal, eager&lt;/td&gt;
&lt;td&gt;+8,168&lt;/td&gt;
&lt;td&gt;+2,456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;normal, deferred&lt;/td&gt;
&lt;td&gt;+384&lt;/td&gt;
&lt;td&gt;+0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heavy, eager&lt;/td&gt;
&lt;td&gt;+3,373,528&lt;/td&gt;
&lt;td&gt;+184&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heavy, deferred&lt;/td&gt;
&lt;td&gt;+176&lt;/td&gt;
&lt;td&gt;+0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The normal provider costs eight kilobytes when it's loaded on every request and nothing when it's deferred. Eight kilobytes, for the whole provider, on every request. That's one file compiled and one object constructed. It's exactly what the docs say deferring saves ("it is not loaded from the filesystem on every request") and it's no more than that. If the app has forty providers like that then deferring all of them is worth about 300KB without OPcache and about 100KB with it. Rendering one small Blade page added about 200KB in the same runs.&lt;/p&gt;

&lt;p&gt;The heavy provider is the row worth staring at. Without OPcache it costs 3.4MB per request because the whole 5,000-entry array gets built from source each time and deferring it takes that to zero. With OPcache it costs 184 bytes per request even when eager, because the array is a literal in a compiled file and OPcache keeps it as an immutable array in the shared segment, and I checked this twice since I didn't believe it: the segment's &lt;code&gt;used_memory&lt;/code&gt; grew by 1.3MB when that provider was added and the request heap didn't move. &lt;code&gt;require&lt;/code&gt; on a file that returns a constant array is close to free under OPcache. &lt;code&gt;require&lt;/code&gt; on a file that computes an array or reads JSON or hits the network is not, and deferring is the fix for that whether OPcache is on or off.&lt;/p&gt;

&lt;p&gt;So deferring's a real tool with a narrow target: providers whose &lt;code&gt;register()&lt;/code&gt; actually does work. Marking a provider that only binds closures as deferred is correct and clean, and it won't show up on any graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten Thousand Models Cost 17MB. A Hundred Thousand Don't Fit
&lt;/h2&gt;

&lt;p&gt;Everything so far was fractions of a megabyte in the production configuration. Here's what the route that loads data did on the same worker with the same OPcache and caches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route (10,000 rows)&lt;/th&gt;
&lt;th&gt;Entry&lt;/th&gt;
&lt;th&gt;Peak&lt;/th&gt;
&lt;th&gt;Peak, real&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Item::query()-&amp;gt;...-&amp;gt;get()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.64MB&lt;/td&gt;
&lt;td&gt;18.44MB&lt;/td&gt;
&lt;td&gt;20.0MB&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB::table('items')-&amp;gt;...-&amp;gt;get()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.64MB&lt;/td&gt;
&lt;td&gt;14.15MB&lt;/td&gt;
&lt;td&gt;16.0MB&lt;/td&gt;
&lt;td&gt;9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-&amp;gt;chunk(1000, fn)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.64MB&lt;/td&gt;
&lt;td&gt;2.58MB&lt;/td&gt;
&lt;td&gt;6.0MB&lt;/td&gt;
&lt;td&gt;52ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-&amp;gt;lazy(1000)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.64MB&lt;/td&gt;
&lt;td&gt;4.25MB&lt;/td&gt;
&lt;td&gt;6.0MB&lt;/td&gt;
&lt;td&gt;52ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-&amp;gt;cursor()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.64MB&lt;/td&gt;
&lt;td&gt;3.17MB&lt;/td&gt;
&lt;td&gt;4.0MB&lt;/td&gt;
&lt;td&gt;122ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-&amp;gt;cursor()&lt;/code&gt;, unbuffered query&lt;/td&gt;
&lt;td&gt;0.68MB&lt;/td&gt;
&lt;td&gt;0.88MB&lt;/td&gt;
&lt;td&gt;2.0MB&lt;/td&gt;
&lt;td&gt;119ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten thousand Eloquent models are &lt;code&gt;17,991,000&lt;/code&gt; bytes on top of the entry point, which is 1,799 bytes per model for a row with eight columns. The same ten thousand rows through the query builder, as plain &lt;code&gt;stdClass&lt;/code&gt; objects with no Eloquent around them, are &lt;code&gt;11,717,416&lt;/code&gt; bytes or 1,172 per row, so the row itself (the strings and the numbers and the object that holds them) is about two thirds of the cost and Eloquent's &lt;code&gt;$attributes&lt;/code&gt; array plus the &lt;code&gt;$original&lt;/code&gt; copy it keeps for dirty checking plus the model object itself is the other third. Neither number is surprising once you know every PHP value carries a header and every array slot costs more than the value inside it. I think, the point is the ratio between them. One &lt;code&gt;get()&lt;/code&gt; on ten thousand rows is fifty-five times the whole framework bootstrap and it's a line most codebases have somewhere, behind an export or a report or an admin page nobody paginated.&lt;/p&gt;

&lt;p&gt;At 100,000 rows the &lt;code&gt;get()&lt;/code&gt; route peaked at &lt;code&gt;183,961,728&lt;/code&gt; bytes. That's 175MB, with the worker's resident memory at 208MB. Under the default &lt;code&gt;memory_limit=128M&lt;/code&gt; it doesn't get that far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
  in /app/vendor/laravel/framework/src/Illuminate/Database/Connection.php on line 442
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a second fatal 65,536 bytes later in &lt;code&gt;ClassLoader.php&lt;/code&gt;, while Laravel tried to autoload the exception handler to report the first one, so the client got a &lt;code&gt;500&lt;/code&gt; with an empty body, nothing reached &lt;code&gt;laravel.log&lt;/code&gt;, and the only evidence of what happened was in the FPM error log.&lt;/p&gt;

&lt;p&gt;The 50,000-row version fit at 88MB, and somewhere between those two numbers is the export that works in staging and dies on the first real customer. The fix isn't a bigger limit, it's the other four rows of the table, and they aren't equal.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;chunk(1000)&lt;/code&gt; peaks at 2.58MB and stays there whether the table has 10,000 rows or 100,000 (2.65MB at 100k). It runs one &lt;code&gt;LIMIT 1000 OFFSET n&lt;/code&gt; query per page, hands the page to the callback, and then (this is in &lt;code&gt;BuildsQueries::chunk&lt;/code&gt;) calls &lt;code&gt;unset($results)&lt;/code&gt; before fetching the next page, so there's one page in memory at a time, and the price is a hundred queries for a hundred thousand rows, 1,088ms in total against 407ms for the single &lt;code&gt;get()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lazy(1000)&lt;/code&gt; peaks at 4.25MB. Higher than chunk, and I think that's because of how the generator's written. The loop is &lt;code&gt;$results = $this-&amp;gt;offset($offset)-&amp;gt;limit($limit)-&amp;gt;get()&lt;/code&gt; followed by a &lt;code&gt;yield&lt;/code&gt; per row, so when page two is fetched page one is still sitting in &lt;code&gt;$results&lt;/code&gt; until the assignment completes, which puts two pages at the peak instead of one, and it's still flat (4.32MB at 100k) and it's still the one I'd pick for a &lt;code&gt;foreach&lt;/code&gt;, because 1.7MB isn't a reason to prefer chunk's callback style over a plain loop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cursor()&lt;/code&gt; is the one people get wrong. I'd got it wrong too before I ran this. It runs a single query and yields one hydrated model at a time so it looks like it should be the smallest of the lot, and at 10,000 rows it's 3.17MB, and at 100,000 rows it's 24.28MB and climbing with the table. The Laravel docs say why in a sentence that's easy to skim past: it "will still eventually run out of memory" because of "PHP's PDO driver internally caching all raw query results in its buffer". A MySQL query is buffered by default, so mysqlnd pulls the entire result set into the PHP process before the loop sees the first row, and with mysqlnd that buffer counts against &lt;code&gt;memory_limit&lt;/code&gt;. So &lt;code&gt;cursor()&lt;/code&gt; saves you the hydrated models and nothing else. It doesn't save you the rows.&lt;/p&gt;

&lt;p&gt;The last row of the table shows what happens when the buffer goes away. I set &lt;code&gt;PDO::MYSQL_ATTR_USE_BUFFERED_QUERY&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; on the connection before the same &lt;code&gt;cursor()&lt;/code&gt; call and the 100,000-row loop peaked at &lt;code&gt;920,976&lt;/code&gt; bytes. That's under a megabyte for the whole table in a single query. The manual's caveat is that an unbuffered result set owns the connection until it's fully read, so any query inside the loop on the same connection fails, and Laravel's own docs point you at &lt;code&gt;lazy()&lt;/code&gt; instead for that reason, and I'd agree with them for anything that touches the database inside the loop. For a pure read-and-stream job an unbuffered cursor is the smallest thing PHP can do with a result set and honestly it's underused.&lt;/p&gt;

&lt;p&gt;One more data point, because it changes the advice depending on the database. I repeated the cursor run against SQLite (a fresh Laravel install ships with it) and &lt;code&gt;cursor()&lt;/code&gt; over 100,000 rows peaked at &lt;code&gt;847,088&lt;/code&gt; bytes. The SQLite driver steps through rows as you ask for them with no client-side buffer. So the "cursor runs out of memory" warning's a MySQL fact rather than a PHP fact.&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%2F4i7a1yejcx8z75nf3p3b.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%2F4i7a1yejcx8z75nf3p3b.png" alt="Horizontal bar chart of peak memory per Laravel route: hello routes under 1MB, 10,000 models with get() at 18.44MB, chunk, lazy and cursor between 0.88 and 4.25MB, and 100,000 models clipped at 175MB" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Okay, But My FPM Workers Show 35MB Each
&lt;/h2&gt;

&lt;p&gt;That's the fair objection, and it's what makes people distrust the 0.66MB figure. &lt;code&gt;ps&lt;/code&gt; on a production box shows &lt;code&gt;php-fpm: pool www&lt;/code&gt; processes at 35MB and 60MB and 90MB and none of them are loading ten thousand models.&lt;/p&gt;

&lt;p&gt;They're not lying, and neither is &lt;code&gt;memory_get_peak_usage()&lt;/code&gt;, they're counting different things. Here's the same worker from the outside in three states with OPcache on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Worker state&lt;/th&gt;
&lt;th&gt;RSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;serving the hello route (measured inside the request)&lt;/td&gt;
&lt;td&gt;30.7MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;serving the 10,000-model &lt;code&gt;get()&lt;/code&gt; (inside the request)&lt;/td&gt;
&lt;td&gt;52.1MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idle, after the 10,000-model request has finished&lt;/td&gt;
&lt;td&gt;36.3MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 30MB floor is the PHP binary and its extensions, plus the pages of the OPcache segment this worker has touched (shared with every other worker, but RSS counts them in each), plus the allocator's first chunk, and the 21MB on top of that during the model request is the heap from the previous section, and the 36MB after the request has finished is the allocator's chunk cache from two sections ago, the worker keeping some of the chunks it peaked at so that a worker that's served one heavy request stays bigger than one that hasn't until enough small requests pull the average back down. Multiply that by a pool where every worker eventually gets one heavy request and you've got the thing everyone observes on a dashboard, FPM memory that only goes up, with no leak anywhere in it.&lt;/p&gt;

&lt;p&gt;Which is exactly why &lt;code&gt;pm.max_children&lt;/code&gt; shouldn't be sized from &lt;code&gt;memory_limit&lt;/code&gt;. The limit is a ceiling on one request's heap rather than a prediction of it, and dividing the box's RAM by 128MB gives you a pool less than half the size it could be for an app whose heaviest route peaks at 20MB. The manual describes &lt;code&gt;pm.max_children&lt;/code&gt; as "the limit on the number of simultaneous requests that will be served". So the number to divide by is what one worker occupies while serving the heaviest route it will actually see. For this app on this box that's 52MB (the 10,000-model route measured from inside), so a 2GB budget for PHP gives about 39 workers, and if some route loads 100,000 models then it's 208MB per worker and 9 workers, and the right move there is to fix that route rather than buy the RAM, because the route that needs 208MB today is the route that'll need 400MB when the table doubles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Actually Do
&lt;/h2&gt;

&lt;p&gt;Measure the peak per route in production and keep the numbers. The cheapest version is a terminable middleware that logs the peak after the response has gone out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;app/Http/Middleware/LogPeakMemory.php&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http\Middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Closure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Http\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Facades\Log&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LogPeakMemory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&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="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'memory_reset_peak_usage'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;memory_reset_peak_usage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// PHP 8.2+, matters under Octane&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'peak'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'route'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'peak_mb'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;]);&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;Append it globally in &lt;code&gt;bootstrap/app.php&lt;/code&gt; and after a day you've got a histogram per route instead of a guess, and the routes at the top of it will be hydration, not bootstrap, so fix those first: paginate the admin page, &lt;code&gt;lazy()&lt;/code&gt; the export, unbuffer the streaming job, and stop calling &lt;code&gt;-&amp;gt;get()&lt;/code&gt; on a query with no &lt;code&gt;LIMIT&lt;/code&gt; in it, because every one of those is worth more than all four bootstrap knobs combined, by a factor I measured at fifty-five.&lt;/p&gt;

&lt;p&gt;Then turn the bootstrap knobs anyway, for time rather than memory. And make sure OPcache is on in every environment where someone is going to read a memory number, so the number they read is the real one.&lt;/p&gt;

&lt;p&gt;Then size the pool from the log, not from &lt;code&gt;memory_limit&lt;/code&gt;. That's it.&lt;/p&gt;

&lt;p&gt;Octane changes the accounting but not the conclusion. It boots the application once per worker and keeps it in memory, so the 316KB is paid once and the per-request cost is only what the route itself allocates. But the worker never exits, and that's the catch. The model that leaks a reference or the static array that grows keeps growing, which is why Octane recycles a worker every 500 requests by default and why the &lt;code&gt;memory_reset_peak_usage()&lt;/code&gt; line above stops being optional. If the Go version of this exercise is more your thing, I did the same measurement for goroutine stacks in &lt;a href="https://dev.to/nazar-boyko/goroutines-are-cheap-their-stacks-arent-4ena"&gt;I Spawned 1000000 Goroutines. Here's Where 13 GB of RAM Went&lt;/a&gt;. The shape of the answer was the same there: the number everyone quotes is true, it's just not the number that gets you paged.&lt;/p&gt;

&lt;p&gt;The interesting part for me wasn't finding one magic Laravel setting that suddenly fixed memory usage. It was almost the opposite! Most of the framework-level optimizations changed far less than I expected, while one ordinary database call could outweigh the entire bootstrap dozens of times over. By the end of the tests, the framework overhead was almost the least interesting number. What the request loaded mattered far more.&lt;/p&gt;

&lt;p&gt;Laravel costs 316KB a request. Your rows cost whatever you ask for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Enjoyed this one? Let's stay in touch — I'm on &lt;a href="https://www.linkedin.com/in/nazar-boyko" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, always happy to chat, swap ideas, or just say hi. 👋&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Million Go Map Entries Take 38MB, Not 16MB</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:47:35 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/a-million-go-map-entries-take-38mb-not-16mb-2ij3</link>
      <guid>https://dev.to/nazar-boyko/a-million-go-map-entries-take-38mb-not-16mb-2ij3</guid>
      <description>&lt;p&gt;A few months ago, I noticed an issue with the internal structure of the new Go map—specifically regarding empty slots! So I decided to figure out why a &lt;code&gt;map[int64]int64&lt;/code&gt; theoretically appears to be 16 bytes per element: 8 bytes for the key + 8 bytes for the value. But in Go 1.27.1, 1,000,000 elements take up approximately 37.8 MB, which is ~37.8 bytes per element. This made me wonder: where do those extra ~22 MB come from?! 🤔 And if you thought that Go simply adds some large header to each element, that’s not the case at all!&lt;/p&gt;

&lt;p&gt;A Go map holding one &lt;code&gt;int64&lt;/code&gt; key and one &lt;code&gt;int64&lt;/code&gt; value takes 192 bytes of heap. A &lt;code&gt;map[int64]int64&lt;/code&gt; with a million entries takes 37.8MB. The napkin math for that second map is eight bytes of key plus eight bytes of value, sixteen bytes an entry, 16MB for the million and the gap between that and what the heap reports is what this whole piece is about. 🤷‍♂️&lt;/p&gt;

&lt;p&gt;It's a follow-up to &lt;a href="https://dev.to/nazar-boyko/goroutines-are-cheap-their-stacks-arent-4ena"&gt;the goroutine stacks piece&lt;/a&gt; and it uses the same method, which boils down to taking something everybody in Go uses without thinking, making a lot of it and forcing a garbage collection to see what the runtime says is still live. The goroutine number held up until the goroutines called something. For maps it doesn't hold up at all. The cost per entry swings between 21 and 44 bytes depending on nothing but how many entries there are, and sets and deletes both behave differently on the current map than most of what's written about them says.&lt;/p&gt;

&lt;p&gt;Everything below ran on Go 1.27.1 on an Apple M5 Max and I reran the byte counts on 1.26.4, which gave the same numbers apart from a little run-to-run noise right where tables split. MB means a million bytes throughout, since that's the unit the napkin math uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Map Most Go Articles Describe Was Replaced In Go 1.24
&lt;/h2&gt;

&lt;p&gt;Search for how Go maps work and most of what comes back is about buckets. A bucket holds up to 8 key/value pairs plus a few high bits of each key's hash, a ninth key that lands in a full bucket gets chained onto an overflow bucket, and once buckets average 6.5 entries the whole map doubles. Every bit of that was true, and it still sits in &lt;code&gt;runtime/map.go&lt;/code&gt; in Go 1.23. There's a comment in there worth remembering. It says the bucket keeps all its keys together and all its values together because alternating them would need padding for something like &lt;code&gt;map[int64]int8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Go 1.24 replaced it with a map that doesn't have buckets at all. Go 1.24's release notes list "a new builtin &lt;code&gt;map&lt;/code&gt; implementation based on Swiss Tables" among the runtime changes that cut CPU overhead by 2 to 3% on average, and Michael Pratt's post on the Go blog shows microbenchmarks where map operations got up to 60% faster than in 1.23. There aren't any overflow chains in the new map and there's no 6.5 either, so plenty of good writing about Go map memory describes a map that hasn't been the default since February 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Entry Costs 192 Bytes, A Million Cost 37.8MB
&lt;/h2&gt;

&lt;p&gt;Here's the part of the harness that does the measuring. It reads &lt;code&gt;HeapAlloc&lt;/code&gt; after a forced GC, fills the maps and reads it again after another one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;main.go&lt;/code&gt;&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;liveHeap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;uint64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GC&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MemStats&lt;/span&gt;
    &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadMemStats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ms&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;ms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HeapAlloc&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// bytesPerMap fills `copies` maps with n entries each and returns the live&lt;/span&gt;
&lt;span class="c"&gt;// heap bytes per map. The keys exist before the first reading, so only the&lt;/span&gt;
&lt;span class="c"&gt;// maps themselves are counted.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;bytesPerMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="n"&gt;comparable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;copies&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;maps&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;copies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;liveHeap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;maps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;maps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;liveHeap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeepAlive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;runtime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeepAlive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// or the GC can free the keys and subtract them&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copies&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;Four details keep the numbers honest. &lt;code&gt;runtime.GC()&lt;/code&gt; doesn't return until the collection and the sweep after it are done, so &lt;code&gt;HeapAlloc&lt;/code&gt; right after it is live memory and not garbage waiting to be swept. Small maps get built many times over (as many copies as fit in two million entries) so a stray runtime allocation can't pass for a per-map cost. And the maps escape into a slice on purpose. A small map that provably never escapes can get its storage on the stack where &lt;code&gt;HeapAlloc&lt;/code&gt; wouldn't ever see it. The last one's the &lt;code&gt;KeepAlive(keys)&lt;/code&gt; line: a trimmed copy of this function that didn't have it reported 29.8 bytes per entry at a million, because when the call's the last place the keys slice gets used the second GC frees its 8MB and takes it off the total.&lt;/p&gt;

&lt;p&gt;Here's &lt;code&gt;map[int64]int64&lt;/code&gt; at a handful of sizes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# go1.27.1 darwin/arm64
     entries      bytes/map  bytes/entry
           1            192        192.0
           8            192         24.0
           9            376         41.8
          14            376         26.9
          15            664         44.3
         100           2392         23.9
         896          18519         20.7
         897          36991         41.2
        1000          36991         37.0
       10000         295585         29.6
      100000        2364444         23.6
     1000000       37812804         37.8
    10000000      302642440         30.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One entry is 192 bytes and so are eight. The ninth entry nearly doubles the map and the fifteenth nearly doubles it again. At 896 entries each one costs 20.7 bytes, the closest a &lt;code&gt;map[int64]int64&lt;/code&gt; got to sixteen in any of my runs. Entry 897 pushes it straight to 41.2, and from there it keeps bouncing between the two with 37.0 at a thousand, 29.6 at ten thousand, 23.6 at a hundred thousand and 37.8 at a million. The keys and values don't change between rows. Only the count does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most Of The Extra Bytes Are Empty Slots
&lt;/h2&gt;

&lt;p&gt;The new map's entries live in groups. A group is 8 slots plus an 8-byte control word with one byte per slot, and each control byte says whether its slot is empty, deleted or full, with the low 7 bits of the key's hash packed into it when the slot is full. That's what lets a lookup check all eight bytes against the hash in one go and only compare the keys whose bits already match. For &lt;code&gt;map[int64]int64&lt;/code&gt; that's a 16-byte slot and a group of &lt;code&gt;8 + 8*16 = 136&lt;/code&gt; bytes.&lt;/p&gt;

&lt;p&gt;A map with 8 or fewer entries is one group and nothing else (the source calls this the small map optimization), and the allocator doesn't have a 136-byte size, it rounds up to its 144-byte size class, while the &lt;code&gt;Map&lt;/code&gt; header next to it is another 48 bytes. That's the 192.&lt;/p&gt;

&lt;p&gt;The ninth entry turns that group into a real table with 16 slots, and from then on a table grows when it's 7/8 full (&lt;code&gt;maxAvgGroupLoad = 7&lt;/code&gt; in &lt;code&gt;group.go&lt;/code&gt;, next to a comment saying it's "the same load factor used by Abseil"). Growing means building a new table twice the size and moving every entry into it. Right after a grow the table's only 7/16 full and more than half of it is empty, then it fills back up to 7/8 before the next doubling, and that's the whole shape of the table above: the cost per entry falls as a table fills and jumps every time it doubles, all the way up to entry 897.&lt;/p&gt;

&lt;p&gt;Tables don't keep doubling past 1024 slots. That limit is &lt;code&gt;maxTableCapacity&lt;/code&gt; in &lt;code&gt;table.go&lt;/code&gt; and the comment above it has a TODO that says "Completely made up value", which I think is the most honest line in the runtime. A full table that size splits into two new 1024-slot tables and a small directory in front of them picks the right table from the hash's top bits. The blog post gives the reason: Go wants every insert to have "an upper bound on the amount of growth work it must do", and moving 896 entries is a small fixed amount of work no matter how big the whole map gets.&lt;/p&gt;

&lt;p&gt;That's where the size-class rounding shows up again. A 1024-slot table is 128 groups or 17408 bytes and the allocator rounds that up to its 18432-byte class, so a table that's exactly as full as it's allowed to be (896 entries) costs 20.6 bytes per entry before any headers and a table that just split costs twice that. And since the hash spreads keys evenly, every table fills at about the same speed and splits at about the same moment, so the sawtooth doesn't smooth out as the map grows and its teeth just get wider. Around 110 thousand entries the map cost 21.4 bytes per entry and around 120 thousand it cost 39.3, and a million entries sits just past the split at 917,504, so that's the whole reason it comes out at 37.8.&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%2Fj8okytv53w2rzt3git89.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%2Fj8okytv53w2rzt3git89.png" alt="Line chart of bytes per entry for a Go map[int64]int64 on Go 1.27.1 from 8 to 16 million entries: a sawtooth between about 21 and 44 bytes, far above a dashed napkin-math line at 16 bytes" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I know this chart looks a bit like a heartbeat monitor... I promise it’s actually Go map memory usage. 😄&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the sixteen bytes are in there. They're the slot. What the napkin math doesn't count is everything around the slot and the biggest part of that is plain emptiness, because in any map past eight entries somewhere between 12.5% and 56% of the slots are empty by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 129-Byte Value Costs Less Than A 128-Byte One
&lt;/h2&gt;

&lt;p&gt;Everything so far used &lt;code&gt;int64&lt;/code&gt; keys and &lt;code&gt;int64&lt;/code&gt; values, and since the slot's the thing that repeats, key and value sizes move the whole number. Here's the same million entries with different types.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Map type&lt;/th&gt;
&lt;th&gt;Slot size in bytes&lt;/th&gt;
&lt;th&gt;Bytes per entry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int32]int32&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;19.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64]int64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;37.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int32]int64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;37.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[string]int64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;55.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64][64]byte&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;167.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64][128]byte&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;136&lt;/td&gt;
&lt;td&gt;301.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64][129]byte&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16, value stored separately&lt;/td&gt;
&lt;td&gt;181.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A slot is the key followed by the value, padded out to the alignment of the bigger one, so &lt;code&gt;map[int32]int64&lt;/code&gt; costs exactly what &lt;code&gt;map[int64]int64&lt;/code&gt; costs because 4 bytes of each of its slots are padding. There's a TODO in &lt;code&gt;table.go&lt;/code&gt; that admits it: interleaving keys and values is good for locality "but it comes at the expense of wasted space for some types". That's the same padding the old bucket comment was avoiding.&lt;/p&gt;

&lt;p&gt;A string key's 16-byte header goes in the slot (a pointer and a length) and the characters live in their own allocation, and since my keys were built before the first reading the 55.8 is the map alone, so a real &lt;code&gt;map[string]int64&lt;/code&gt; pays that plus whatever its strings cost.&lt;/p&gt;

&lt;p&gt;The last two rows are the ones that don't make sense at first. Keys and values bigger than 128 bytes don't go in the slot at all: &lt;code&gt;MapMaxKeyBytes&lt;/code&gt; and &lt;code&gt;MapMaxElemBytes&lt;/code&gt; are both 128 in &lt;code&gt;internal/abi&lt;/code&gt; and anything bigger gets its own allocation with a pointer in the slot. A &lt;code&gt;[129]byte&lt;/code&gt; value costs a 16-byte slot plus a 144-byte object and a &lt;code&gt;[128]byte&lt;/code&gt; value costs a 136-byte slot, so on paper the inline one should win. Even with one table as full as it can get (896 entries) the two only tie at 164.7 bytes each, since the inline version's groups array is over 32KB and anything that big gets rounded up to whole 8KB pages. At a million entries it's the 128-byte map that's 120 bytes per entry bigger. Turns out that's the empty slots again. An inline value takes all of its bytes in every slot, the empty ones included, while an empty slot in the pointer version holds just 16 bytes of key and pointer.&lt;/p&gt;

&lt;p&gt;I wouldn't reshape a struct around the 128-byte line. But it explains how a big map of 100-byte structs ends up so much fatter than expected, since those 100 bytes get paid in every empty slot too, and &lt;code&gt;map[K]*V&lt;/code&gt; does by hand what the runtime does past 128 bytes at the price of a pointer the garbage collector has to follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A struct{} Set Costs Exactly What A bool Set Costs
&lt;/h2&gt;

&lt;p&gt;The standard advice for a set in Go is &lt;code&gt;map[string]struct{}&lt;/code&gt; because &lt;code&gt;struct{}&lt;/code&gt; doesn't take any bytes and &lt;code&gt;bool&lt;/code&gt; takes one. On Go 1.23 that advice was right, by a little: I built the same harness with Go 1.23.12 (the last release line where the bucket map was the default) and a million-entry &lt;code&gt;map[int64]struct{}&lt;/code&gt; came out at 22.3 bytes per entry against 24.6 for &lt;code&gt;map[int64]bool&lt;/code&gt;, and that's because the bucket kept its 8 values in their own array where 8 zero-size values take zero bytes.&lt;/p&gt;

&lt;p&gt;Here's how sets look on the three layouts I could build, all at a million entries.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Map type&lt;/th&gt;
&lt;th&gt;Go 1.23.12 (buckets)&lt;/th&gt;
&lt;th&gt;Go 1.27.1&lt;/th&gt;
&lt;th&gt;Go 1.27.1 with mapsplitgroup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64]struct{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;37.8&lt;/td&gt;
&lt;td&gt;21.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64]bool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24.6&lt;/td&gt;
&lt;td&gt;37.8&lt;/td&gt;
&lt;td&gt;21.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[string]struct{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40.1&lt;/td&gt;
&lt;td&gt;55.9&lt;/td&gt;
&lt;td&gt;39.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[string]bool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42.3&lt;/td&gt;
&lt;td&gt;55.8&lt;/td&gt;
&lt;td&gt;39.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;map[int64]int64&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40.2&lt;/td&gt;
&lt;td&gt;37.8&lt;/td&gt;
&lt;td&gt;37.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the current default map the two set types cost the same (the string rows wobble by 0.1 between runs, in both directions) and both cost a lot more than they did on 1.23. It's the slot again. The slot's a struct with the key first and the value last. The compiler's rule is that any non-empty struct ending in a zero-size field gets one extra byte of padding, so that a pointer to that last field can't point past the end of the object (that rule goes back to issue 9401 from 2014). Alignment then rounds that one byte up to eight, and a &lt;code&gt;bool&lt;/code&gt; is also one byte that gets rounded up to the same eight.&lt;/p&gt;

&lt;p&gt;Three weeks before 1.24 shipped Michael Pratt opened issue 71368 about exactly this: "With swissmaps in 1.24, a &lt;code&gt;map[int64]struct{}&lt;/code&gt; requires 16 bytes of space per slot, rather than the expected 8 bytes." Someone in the thread asked whether &lt;code&gt;map[X]bool&lt;/code&gt; was just as bad and got a one-line answer: "Yes, both now allocate exactly the same amount of memory."&lt;/p&gt;

&lt;p&gt;One map size can flatter one layout because 1.23 and 1.27 double at different points, so I reran the sets at a hundred thousand and five million entries too. The &lt;code&gt;int64&lt;/code&gt; set on 1.27.1 was 1.5 to 1.7 times its 1.23 size at all three sizes, while &lt;code&gt;map[int64]int64&lt;/code&gt; and &lt;code&gt;map[string]int64&lt;/code&gt; came out smaller on the new layout at all three (40.2 against 37.8 in the last row). Slots that need padding are where it's gone backwards, and the issue calls a set "the most extreme case" of that.&lt;/p&gt;

&lt;p&gt;There's a fix already. Jake Bailey's change lays out a group as all 8 keys followed by all 8 values (KKKKVVVV instead of KVKVKVKV) and it was merged in March as &lt;code&gt;GOEXPERIMENT=mapsplitgroup&lt;/code&gt;, but Go 1.27 ships with it switched off, so the last column is 1.27.1 with it switched on. With it on, sets drop just below their 1.23 size and &lt;code&gt;map[int32]int64&lt;/code&gt; goes from 37.8 to 27.9 while &lt;code&gt;map[int64]int64&lt;/code&gt; doesn't move because it never had padding to lose. On August 24 Michael Pratt's own change turned it on by default on the development branch and closed the issue under the Go 1.28 milestone. Even with split groups &lt;code&gt;struct{}&lt;/code&gt; and &lt;code&gt;bool&lt;/code&gt; still tie, though. Eight &lt;code&gt;bool&lt;/code&gt; values fit in the same 8 bytes the padding would've taken.&lt;/p&gt;

&lt;h2&gt;
  
  
  make(map, n) Makes The Map Faster, Not Smaller
&lt;/h2&gt;

&lt;p&gt;Preallocating is the other standard advice and the natural guess is that it makes the map smaller too, since a map built to size doesn't have to overshoot. Here's the same set of sizes built both ways.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# go1.27.1 darwin/arm64
   entries  grown bytes/ent make(n) bytes/ent
       100             23.9             23.9
      1000             37.0             37.0
     10000             29.6             29.6
     50000             23.6             23.6
    100000             23.6             23.6
    500000             37.8             37.8
   1000000             37.8             37.8
  10000000             30.3             30.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same numbers. A size hint asks for enough slots to hold n entries at 7/8 load and rounds the table and directory sizes up to powers of two, and that lands on the same layout growth reaches anyway because all the tables split together. Across the full sweep the two only disagreed right at split points, and not always in the hint's favor: at 897 entries the hinted map was 31.2 bytes per entry against 41.2 for the grown one, but at about 1.9 million it was 39.3 against 37.8.&lt;/p&gt;

&lt;p&gt;The hint's payoff is in the work of getting there. Here's the &lt;code&gt;testing.B&lt;/code&gt; side of the measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fill_test.go&lt;/code&gt;&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;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"strconv"&lt;/span&gt;
    &lt;span class="s"&gt;"testing"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="n"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sink&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;BenchmarkFill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"grow/"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Itoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportAllocs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;sink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"make_n/"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Itoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportAllocs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;sink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-run&lt;/span&gt; &lt;span class="s1"&gt;'^$'&lt;/span&gt; &lt;span class="nt"&gt;-bench&lt;/span&gt; BenchmarkFill &lt;span class="nt"&gt;-benchmem&lt;/span&gt; &lt;span class="nt"&gt;-count&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BenchmarkFill/grow/1000-18             40568         27625 ns/op       74456 B/op         22 allocs/op
BenchmarkFill/make_n/1000-18          193857          6219 ns/op       36992 B/op          6 allocs/op
BenchmarkFill/grow/100000-18             478       2472747 ns/op     4729552 B/op        532 allocs/op
BenchmarkFill/make_n/100000-18          1441        838571 ns/op     2364600 B/op        258 allocs/op
BenchmarkFill/grow/1000000-18             25      42785217 ns/op    75605811 B/op       8209 allocs/op
BenchmarkFill/make_n/1000000-18           38      30394810 ns/op    37832752 B/op       4098 allocs/op
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the first of the three runs, the other two were within about 5%. The &lt;code&gt;B/op&lt;/code&gt; column is the cross-check against the heap numbers, and it holds up: a preallocated thousand-entry map allocated 36992 bytes and &lt;code&gt;HeapAlloc&lt;/code&gt; said exactly 36992 bytes were live, while at a million entries the preallocated map's live bytes and its B/op differ by 1.5KB out of 37.8MB. A growing map allocates almost exactly twice its final size because each doubling throws the previous table away. On time, the hint made filling a thousand entries 4.4 times faster and a hundred thousand 2.9 times faster, but a million only 1.4 times. I'd guess that at a million most of the time goes into writing 38MB of memory the process has never touched, which both versions pay for equally. I didn't profile it to check though.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deleting Every Key Gives Back Zero Bytes
&lt;/h2&gt;

&lt;p&gt;Go issue 20135 ("runtime: shrink map as elements are deleted") was opened in April 2017 and it's still open, but everything in its thread was written about the bucket map, so deletes were the part worth checking again on the new one. The steps are in the labels below, and every line was read after a forced GC and measured against the heap from before the map existed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# go1.27.1 darwin/arm64
filled                             len=1000000  heap=   37798.2 KB
deleted every key                  len=0        heap=   37801.2 KB
refilled with new keys             len=1000000  heap=   37838.2 KB
clear(m)                           len=0        heap=   37838.2 KB
refilled, deleted 99%              len=10000    heap=   37838.3 KB
copied survivors to a new map      len=10000    heap=     301.2 KB
m = nil                            len=0        heap=       5.6 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting a million keys didn't free a byte. &lt;code&gt;clear(m)&lt;/code&gt; freed nothing either. The map that went from a million entries down to ten thousand still held 37.8MB, nearly 3.8KB for each entry left in it, and the only line that moved at all is the one where the ten thousand survivors went into a fresh map and the old one got dropped.&lt;/p&gt;

&lt;p&gt;The source says the same thing and it doesn't need any measuring. &lt;code&gt;Clear&lt;/code&gt; walks every table and marks every slot empty and right after that loop sits a comment that just says &lt;code&gt;TODO: shrink directory?&lt;/code&gt;, while &lt;code&gt;Delete&lt;/code&gt; marks a slot empty or leaves a tombstone if its group is full, and nothing in &lt;code&gt;internal/runtime/maps&lt;/code&gt; hands a table back while the map's alive. The old &lt;code&gt;for k := range m { delete(m, k) }&lt;/code&gt; loop compiles into the same runtime call as &lt;code&gt;clear(m)&lt;/code&gt; too. Brad Fitzpatrick pointed out in the same issue that the compiler rewrites that loop into &lt;code&gt;runtime.mapclear&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Okay, but doesn't the garbage collector clean up after a delete? It cleans up what the deleted entry pointed to, and that's it. Keith Randall spelled out the split in that thread back in 2020: "the space for the keys and values themselves won't be reclaimed, as that space is part of the buckets. Only the things &lt;em&gt;referenced&lt;/em&gt; by the keys and values will be collected." Swap buckets for groups and it describes the new map just as well. When a session gets deleted from a &lt;code&gt;map[string]*Session&lt;/code&gt; the &lt;code&gt;Session&lt;/code&gt; and the key's characters can be collected (if nothing else points at them) but the 24-byte slot stays where it is.&lt;/p&gt;

&lt;p&gt;The map doesn't keep growing forever, though. Deleted slots get reused, which the "refilled with new keys" line shows well enough: a million new keys went into the emptied map and the heap moved by 37KB. I also ran a sliding window of a hundred thousand live keys where every insert deletes the oldest key, kept it going for twenty million inserts, and watched the map go from 2.36MB to 4.73MB by the two-million mark and then sit there for the other eighteen million. So a map with steady turnover settles at about twice its freshly built size, and a map that was once big stays as big as it ever got.&lt;/p&gt;

&lt;p&gt;Josh Bleecher Snyder's first comment on the issue in 2017 is still the whole workaround: "The only available workaround is to make a new map and copy in elements from the old." It's cheaper than it sounds as long as it isn't done after every single delete. Someone in the thread asked about copying to a new map after O(n) deletes and Keith Randall's reply was "That would work fine."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change In Real Code
&lt;/h2&gt;

&lt;p&gt;Sets first. I'd still write &lt;code&gt;map[string]struct{}&lt;/code&gt; because it tells the next reader the values mean nothing, but on Go 1.24 through 1.27 it doesn't save a single byte over &lt;code&gt;bool&lt;/code&gt;, and honestly I wouldn't flip a &lt;code&gt;GOEXPERIMENT&lt;/code&gt; in production just to get the split layout early. When the keys are small integers from a dense range the map is the wrong shape anyway, and the difference isn't small: a bitset for a million possible IDs (&lt;code&gt;make([]uint64, 15625)&lt;/code&gt;) was 128KB of heap, the same million as a &lt;code&gt;map[int64]struct{}&lt;/code&gt; was 37.8MB, and a plain &lt;code&gt;[]int64&lt;/code&gt; indexed by the key took 8.0MB.&lt;/p&gt;

&lt;p&gt;I'd preallocate whenever the size is known up front. The map ends up the same size but it gets there 1.4 to 4.4 times faster with half the garbage, and garbage is GC work later.&lt;/p&gt;

&lt;p&gt;After a big delete I'd rebuild the map. That means copying what's left into &lt;code&gt;make(map[K]V, len(old))&lt;/code&gt; and dropping the old one, since nothing else gives the memory back.&lt;/p&gt;

&lt;p&gt;And I'd measure the maps in the real service before rewriting any of them. A heap profile works, with one detail worth knowing before opening it: for a map made without a size hint, pprof puts the memory on the line that inserts into the map and not on the &lt;code&gt;make&lt;/code&gt; line, because the tables get allocated as the map grows. My million-entry set showed up as 36.06MB on its assignment line, so pprof's MB is 1024 times 1024 bytes so that's the same 37.8MB.&lt;/p&gt;

&lt;p&gt;The map with one &lt;code&gt;int64&lt;/code&gt; in it is still my favorite number from all of this. Sixteen bytes of entry, 176 bytes of map. &lt;/p&gt;

&lt;p&gt;I tried to break all of this down in as much detail as possible and verify the numbers along the way. That said, this goes pretty deep into Go’s runtime and memory layout, so I may have missed something or made a mistake in one of the calculations. 🙄 If you spot anything that looks wrong, I’d genuinely appreciate a correction.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Enjoyed this one? Let's stay in touch — I'm on &lt;a href="https://www.linkedin.com/in/nazar-boyko" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, always happy to chat, swap ideas, or just say hi. 👋&lt;/em&gt;&lt;/p&gt;

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