<?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>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;150&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;
              

              112&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&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 "&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>
    <item>
      <title>I Added One Key to a PHP Array. It Cost 25 MB of Memory</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:05:40 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/i-added-one-key-to-a-php-array-it-cost-25-mb-of-memory-4b80</link>
      <guid>https://dev.to/nazar-boyko/i-added-one-key-to-a-php-array-it-cost-25-mb-of-memory-4b80</guid>
      <description>&lt;p&gt;I was recently refactoring some legacy PHP code and noticed that the same data array was being used in completely different ways! Radically different!  After some analysis, I came to the conclusion that a PHP array can be a very efficient data structure as long as it remains a packed list, but as soon as it turns into a hash table, memory consumption skyrockets, and for large datasets, associative arrays often consume nearly twice as much memory as regular typed objects.&lt;/p&gt;

&lt;p&gt;In this article, I want to explain why this happens and what conclusions I've reached, because as I was told in the PHP community, only God knows where the memory goes 😀&lt;/p&gt;

&lt;p&gt;After doing some analysis, I found that a PHP array holding one million integers takes 16.8 MB on PHP 8.4. If you add a single-character key, it takes up 41.9 MB. That means a single key takes up 25 MB. 🤷‍♂️&lt;/p&gt;

&lt;p&gt;I'd read that PHP arrays are hash tables so many times I'd stopped hearing it, it sits in the same drawer as "floats are inexact". So I ended up building arrays of a million elements and watching &lt;code&gt;memory_get_usage()&lt;/code&gt; while I did things to them. I started with the case PHP optimizes, then the ways that optimization quietly goes away, then a million rows from a query with each row an associative array, because every codebase I've worked on holds its rows that way. The rows are where the real cost is and the fix turns out to be smaller than the problem.&lt;/p&gt;

&lt;p&gt;Everything below ran on PHP 8.4.21 in the official &lt;code&gt;php:8.4-cli&lt;/code&gt; Docker image on 64-bit Linux with &lt;code&gt;memory_limit&lt;/code&gt; set to &lt;code&gt;-1&lt;/code&gt; so nothing died halfway and where the version matters I ran the same script on 8.1.34 too, because 8.2 changed one of these numbers a lot and I wanted the before as well as the after. The figures are deltas of &lt;code&gt;memory_get_usage()&lt;/code&gt;. The PHP manual says those are rounded up to the allocator's granularity, so treat the last couple of digits as noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sixteen bytes per integer, until you add one key
&lt;/h2&gt;

&lt;p&gt;Here's the sample code I used; this code for measuring memory usage isn't complicated at all. You can try replicating it too! The measuring code is nothing clever. Build the array, subtract two calls to &lt;code&gt;memory_get_usage()&lt;/code&gt;, divide by the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;bench.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="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;N&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$n&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="nb"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%-42s %14s bytes  %6.2f bytes/elem&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;number_format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$before&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;$a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nc"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'packed list, keys 0..N-1 in order'&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="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;N&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$before&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;$r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;N&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'same keys, filled in reverse order'&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="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;N&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two loops use the same keys and the same values. One counts up and the other counts down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP 8.4.21 (Linux)
packed list, keys 0..N-1 in order          16,781,392 bytes   16.78 bytes/elem
range(0, N-1)                              16,781,392 bytes   16.78 bytes/elem
same keys, filled in reverse order         41,943,120 bytes   41.94 bytes/elem
SplFixedArray of N ints                    16,003,192 bytes   16.00 bytes/elem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two and a half times the memory for the same million integers, because I filled it backwards.&lt;/p&gt;

&lt;p&gt;That's the whole article in one line, honestly. The rest is why. 😃&lt;/p&gt;

&lt;p&gt;The first array is what the engine calls &lt;strong&gt;packed&lt;/strong&gt;. The keys are 0 and 1 and 2 and so on in order, so PHP doesn't store them at all, each slot is one 16-byte zval and that's it. The reverse-filled array has the same keys but they arrived out of order, so it's a real hash table with keys and hashes and an index at about 40 bytes per slot.&lt;/p&gt;

&lt;p&gt;Then the part that made me sit up. Take the packed million and give it one string key.&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="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/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;after adding one string key to packed list    +25,161,728 bytes
after adding key -1 to packed list            +25,161,728 bytes
after unset of that string key                +25,161,728 bytes (nothing came back)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One key. Twenty-five megabytes. And as it turns out, removing the key does not undo this action, because nothing on the unset path converts a hash table back into a packed one (&lt;code&gt;sort()&lt;/code&gt; does, as it happens, and &lt;code&gt;array_values()&lt;/code&gt; builds a fresh packed array, but plain &lt;code&gt;unset&lt;/code&gt; only frees the value and leaves the layout alone). A negative key does the same thing as a string key, and I'm not sure why that surprised me since as an unsigned value it's enormous, but it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A packed array is a flat zval array; a hash array is buckets plus an index
&lt;/h2&gt;

&lt;p&gt;Every PHP array is a &lt;code&gt;zend_array&lt;/code&gt; and the C code calls it &lt;code&gt;HashTable&lt;/code&gt; too. It's 56 bytes on 64-bit. Most of it is bookkeeping (a refcount header and flags and the table size and the element count and the next free integer key and a destructor pointer) and then there's one pointer to the data, and that pointer is a union, and the union is the whole story:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Zend/zend_types.h (php-src, PHP-8.4 branch)&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;union&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt;     &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;arHash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;Bucket&lt;/span&gt;       &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;arData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;zval&lt;/span&gt;         &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;arPacked&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;In hash mode the data block has two parts. In front sits the hash index, an array of &lt;code&gt;uint32_t&lt;/code&gt; slots with twice as many slots as there are buckets (the mask is &lt;code&gt;-(nTableSize + nTableSize)&lt;/code&gt;) so that's 8 bytes of index per bucket and behind it sit the buckets at 32 bytes each:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Zend/zend_types.h (php-src, PHP-8.4 branch, comments mine)&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;_Bucket&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;zval&lt;/span&gt;              &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* 16 bytes: the value */&lt;/span&gt;
    &lt;span class="n"&gt;zend_ulong&lt;/span&gt;        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="cm"&gt;/* 8 bytes: the integer key, or the string key's hash */&lt;/span&gt;
    &lt;span class="n"&gt;zend_string&lt;/span&gt;      &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="cm"&gt;/* 8 bytes: NULL for integer keys */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A lookup hashes the key and masks it into the index and reads a bucket number there and follows a &lt;code&gt;next&lt;/code&gt; chain stored inside the zval if two keys collided. Iteration never touches the index, it walks the buckets front to back, and since buckets are appended in insertion order that's how &lt;code&gt;foreach&lt;/code&gt; gives you insertion order for free.&lt;/p&gt;

&lt;p&gt;So a hash slot costs 32 + 8 = 40 bytes. A million elements need a table of 1,048,576 slots (more on that in a second), and 40 times that is 80 bytes short of what the measurement says. That's the 56-byte struct plus 24 bytes the allocator keeps to track a block that big.&lt;/p&gt;

&lt;p&gt;In packed mode there's no index (two placeholder slots and 8 bytes total) and the data is &lt;code&gt;zval *arPacked&lt;/code&gt;, a bare C array of 16-byte values where the position is the key. Sixteen times the same table size lands about 4 KB under the measured 16.8 MB. It's the same accounting with a smaller slot.&lt;/p&gt;

&lt;p&gt;Here's the thing I hadn't tracked: packed arrays only became this cheap in PHP 8.2. Before that they used the same 32-byte buckets as hash arrays and simply skipped the index, with &lt;code&gt;h&lt;/code&gt; repeating the slot's position and &lt;code&gt;key&lt;/code&gt; always NULL in every slot. That lasted until Dmitry Stogov's &lt;a href="https://github.com/php/php-src/pull/7491" rel="noopener noreferrer"&gt;PR #7491&lt;/a&gt; ("Use more compact representation for packed arrays") was merged in November 2021 and shipped in 8.2.0 in December 2022. It's an internals change, so the 8.2 UPGRADING notes don't mention it at all (I went looking). Here's the 8.1 run for the before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP 8.1.34 (Linux)
packed list, keys 0..N-1 in order          33,558,608 bytes   33.56 bytes/elem
same keys, filled in reverse order         41,943,120 bytes   41.94 bytes/elem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half the slot memory of every list in your application, from one release. Tideways company measured the same thing when 8.2 came out, on a 100,000-element list, and got 4.3 MB down to 2.3 MB. If you're still on 8.1 for some reason, this is a decent argument on its own.&lt;/p&gt;

&lt;p&gt;The other rule that's worth knowing is growth. Table sizes are powers of two with a minimum of 8, and a full table doubles (&lt;code&gt;nSize = ht-&amp;gt;nTableSize + ht-&amp;gt;nTableSize&lt;/code&gt; in &lt;code&gt;zend_hash_do_resize&lt;/code&gt;) so one million elements live in a table of 1,048,576 slots. Which means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packed list of 1,048,576 ints              16,781,448 bytes   16.00 bytes/elem
packed list of 1,048,577 ints              33,558,664 bytes   32.00 bytes/elem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more element and twice the memory. And it doesn't go the other way. I unset 999,000 of the million and &lt;code&gt;memory_get_usage()&lt;/code&gt; moved by exactly 0 bytes. The values were integers so there was nothing to free, and the table itself doesn't shrink. An array remembers the biggest it has ever been. In a queue worker that's one of the reasons memory only ever climbs, and long-running PHP processes goes through the rest of them.&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%2Fedv4wwd2k2q1w0lzf0wb.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%2Fedv4wwd2k2q1w0lzf0wb.png" alt="Diagram comparing a packed PHP array, a 56-byte zend_array pointing to eight 16-byte zvals behind an 8-byte placeholder index, with a hash array, whose uint32 hash index has twice as many slots as its eight 32-byte buckets (zval 16, h 8, key 8); per slot 16 bytes packed and 40 bytes hashed" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules for staying packed are stricter than array_is_list()
&lt;/h2&gt;

&lt;p&gt;The conversion logic lives in &lt;code&gt;_zend_hash_index_add_or_update_i&lt;/code&gt; in &lt;code&gt;Zend/zend_hash.c&lt;/code&gt;. For an array that's currently packed, an integer key goes through this sequence.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key below the high-water mark, slot filled.&lt;/strong&gt; Overwrite in place. Still packed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key below the high-water mark, slot is a hole.&lt;/strong&gt; Convert to hash. The comment in the source reads &lt;code&gt;/* we have to keep the order :( */&lt;/code&gt;, and that's the real reason for all of this. A PHP array promises to iterate in insertion order, a packed array can only express "ascending", so any insert that would break ascending order forces the full structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key inside the current table size.&lt;/strong&gt; Append, fill any gap with undefined slots, stay packed. Writing &lt;code&gt;$a[0] = 'a'; $a[5] = 'b';&lt;/code&gt; gives a packed array with four holes in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key past the table but less than twice its size, table more than half full.&lt;/strong&gt; Grow, stay packed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything else, every string key, and any negative key&lt;/strong&gt; (as an unsigned value it's enormous). Convert to hash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here are two arrays with the same two elements and a different order of arrival.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0 =&amp;gt; 'b', 1 =&amp;gt; 'a'] built at runtime      216 bytes
[1 =&amp;gt; 'a', 0 =&amp;gt; 'b'] built at runtime      376 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that costs 376 bytes is a hash table with eight buckets and sixteen index slots. For two values. The 216-byte one is eight zvals and nothing else. Both are the minimum table size.&lt;/p&gt;

&lt;p&gt;The gotcha that actually bites is &lt;code&gt;array_filter()&lt;/code&gt;. It preserves keys and everyone knows that because of the JSON symptom where a filtered list encodes as an object. The memory symptom isn't as loud. Filter a packed million down to the even numbers and the result gets built key by key (0 then 2 then 4 then 6) until key 8 arrives and doesn't fit in the initial table of 8 and also fails the "more than half full" test, at which point the new array converts to hash on its fifth element and stays there for the rest of its life.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;array_filter keeping every other element   20,971,600 bytes   41.94 bytes/elem
array_values() of that result               8,392,784 bytes   16.79 bytes/elem
array_is_list(filtered) = false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half the elements and more memory than the original packed million. &lt;code&gt;array_values()&lt;/code&gt; fixes it at the cost of one copy, and I think that's always worth paying if the result is going to live for a while.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;array_is_list()&lt;/code&gt; checks keys and not layout. It arrived in 8.1 and I'd assumed it was the test for this. It answers "are the keys 0 to n-1 in order" and a hash table can satisfy that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1 =&amp;gt; 'a', 0 =&amp;gt; 'b']                       376 bytes   array_is_list = false
after unset($b[1])                         376 bytes   array_is_list = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same 376 bytes and the same hash layout, and now the function says it's a list. Before PHP 8.4 no userland call showed you the layout, and the only honest instrument was &lt;code&gt;memory_get_usage()&lt;/code&gt;. Since 8.4 &lt;code&gt;debug_zval_dump()&lt;/code&gt; prints &lt;code&gt;packed&lt;/code&gt; next to a packed array, and for this one it prints nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy costs nothing until the first write
&lt;/h2&gt;

&lt;p&gt;The refcount lives in the &lt;code&gt;zend_array&lt;/code&gt; header, not in the variable. &lt;code&gt;$b = $a&lt;/code&gt; bumps it and points both variables at the same table, and passing an array into a function does the same. And I've seen a rule somewhere that says we are only permitted to modify structures that we exclusively own, which means that they must have a refcount of one. Otherwise the engine separates first, and separation means duplicating the table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$copy = $a                                          0 bytes
$copy[] = 1 (first write)                  16,781,392 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an array of rows there's a detail that works in your favor. Separation copies the outer table and bumps the refcount on each row, it doesn't deep-copy the rows. Appending to a copy of a million-row array cost 16.8 MB (the outer packed table), and changing one field inside one row of that copy cost another 376 bytes for that single row's hash table. The engine copies exactly what you touched, one level at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A million rows as arrays cost twice what the same rows as objects do
&lt;/h2&gt;

&lt;p&gt;This is the part I actually wanted to know. Query results as associative arrays are the default in every PHP codebase I've seen (either &lt;code&gt;fetchAll(PDO::FETCH_ASSOC)&lt;/code&gt; or a query builder that returns the same shape). I'd guess most of the arrays alive in a typical request are hash arrays for exactly that reason, they came out of a query or a JSON body with string keys. So the test is a million rows of five fields with the same values in every container.&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;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Row&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;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$balance&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;span class="c1"&gt;// each shape, built a million times in a loop and kept in a list&lt;/span&gt;
&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;'balance'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;@example.com"&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="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;@example.com"&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="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bytes per row for a million rows, including the two strings and the slot in the outer list:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;PHP 8.4.21&lt;/th&gt;
&lt;th&gt;PHP 8.1.34&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Class with 5 declared typed properties&lt;/td&gt;
&lt;td&gt;233&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same class, &lt;code&gt;readonly&lt;/code&gt; properties&lt;/td&gt;
&lt;td&gt;233&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packed list per row (the &lt;code&gt;FETCH_NUM&lt;/code&gt; shape)&lt;/td&gt;
&lt;td&gt;321&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SplFixedArray(5)&lt;/code&gt; per row&lt;/td&gt;
&lt;td&gt;323&lt;/td&gt;
&lt;td&gt;324&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Associative array per row (the &lt;code&gt;FETCH_ASSOC&lt;/code&gt; shape)&lt;/td&gt;
&lt;td&gt;481&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;stdClass&lt;/code&gt; per row (the &lt;code&gt;FETCH_OBJ&lt;/code&gt; shape)&lt;/td&gt;
&lt;td&gt;529&lt;/td&gt;
&lt;td&gt;546&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing about this table: all six shapes ran one after another in a single process, and that makes the object rows look a little cheaper than they are. Every object also takes an 8-byte entry in the engine's object store, which doubles when it fills up and never shrinks, and the later object runs reused the entries (and some other bookkeeping) the &lt;code&gt;stdClass&lt;/code&gt; run had already paid for. Measured each in a fresh process, a class row is 241 bytes on 8.4 and 258 on 8.1, and a &lt;code&gt;SplFixedArray(5)&lt;/code&gt; row is 340 on 8.4 and 341 on 8.1. The array rows and &lt;code&gt;stdClass&lt;/code&gt; come out the same.&lt;/p&gt;

&lt;p&gt;The two strings are the same in every row. "user123456" and "&lt;a href="mailto:user123456@example.com"&gt;user123456@example.com&lt;/a&gt;" cost 40 and 48 bytes each: a 24-byte &lt;code&gt;zend_string&lt;/code&gt; header plus the characters plus a terminator, rounded up to an allocator bin. The slot in the outer array is 16.78. Take those 105 bytes off and what's left is the container, which I also measured one row at a time to be sure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;assoc row, 5 string keys                   376 bytes
stdClass row, 5 dynamic properties         416 bytes
packed row, 5 values                       216 bytes
SplFixedArray(5) row                       176 bytes
object row, 5 declared properties          128 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 376 is the number from the previous section: a 56-byte header plus a hash block sized for the minimum of 8 buckets (8 x 32 for the buckets and 16 x 4 for the index), which comes to 320 bytes. For five fields. Every row carries its own index, its own copy of the key hashes and pointers, and three empty buckets.&lt;/p&gt;

&lt;p&gt;The object is 128 because a &lt;code&gt;zend_object&lt;/code&gt; header is 40 bytes and declared properties are stored inline right after it at one 16-byte zval each (so 40 + 5 x 16 = 120 and then the allocator rounds that up to its 128 bin). The property names aren't in the object at all, because the class holds one table that maps each name to a slot offset (built once at compile time) and every instance is just the slots.&lt;/p&gt;

&lt;p&gt;Typed or untyped and &lt;code&gt;readonly&lt;/code&gt; or not makes no difference to the bytes. The type lives in the class and the slot is a zval either way, so the readonly DTO you'd write for correctness reasons is also the cheapest way to hold a row.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stdClass&lt;/code&gt; is the worst of both. It has the 40-byte object header and no declared properties, so every field goes into the object's own &lt;code&gt;properties&lt;/code&gt; hash table, and that table is exactly the 376-byte array from before with an object wrapped around it. &lt;code&gt;FETCH_OBJ&lt;/code&gt; gives you exactly this shape a million times over. PHP 8.2 deprecated dynamic properties on ordinary classes but &lt;a href="https://wiki.php.net/rfc/deprecate_dynamic_properties" rel="noopener noreferrer"&gt;the RFC&lt;/a&gt; marks &lt;code&gt;stdClass&lt;/code&gt; with &lt;code&gt;#[AllowDynamicProperties]&lt;/code&gt;, so it isn't going anywhere.&lt;/p&gt;

&lt;p&gt;The packed list per row is a nice illustration of the 8.2 change. On 8.4 it's 216 bytes: a header plus eight 16-byte slots rounded into the 160 bin. On 8.1 it's 376, identical to the associative version, because packed buckets were 32 bytes back then and eight of them plus the tiny index landed in the same 320-byte bin. So &lt;code&gt;FETCH_NUM&lt;/code&gt; saved nothing at all before 8.2 and now it saves a third and costs you the column names.&lt;/p&gt;

&lt;p&gt;Okay, but the driver hands me arrays. Turning a million of them into objects is a million constructor calls. Yes, and the constructor's cheap next to the query you already paid for. PDO can instantiate a class per row with &lt;code&gt;FETCH_CLASS&lt;/code&gt; (though it assigns properties before calling the constructor unless you add &lt;code&gt;FETCH_PROPS_LATE&lt;/code&gt;, and that makes constructor promotion awkward enough that I'd rather write the one mapping line myself anyway) but the better answer to "a million constructor calls" is that you shouldn't be holding a million of anything, and that's the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  SplFixedArray wins on flat lists, and ext-ds doesn't change the picture
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SplFixedArray&lt;/code&gt; is a C struct with a &lt;code&gt;zend_long size&lt;/code&gt; and a &lt;code&gt;zval *elements&lt;/code&gt; buffer allocated at exactly &lt;code&gt;size&lt;/code&gt; times &lt;code&gt;sizeof(zval)&lt;/code&gt;. There's no hash and no index and no power-of-two rounding. A million integers cost 16.00 bytes per element exactly against the packed array's 16.78 (the packed array rounded its table up to the next power of two). That 5% is the entire memory win, and in exchange you give up growth, string keys and every &lt;code&gt;array_*&lt;/code&gt; function. For a five-field row it's 176 bytes (an object header plus the struct plus a separate 80-byte element buffer), and that's more than the class.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ext-ds&lt;/code&gt; (the PHP extension) I actually compiled for this, because it's the thing people bring up whenever PHP data structures come up, with one note before the numbers: &lt;code&gt;pecl install ds&lt;/code&gt; gave me 2.0.0, and on this build it declared &lt;code&gt;Ds\Seq&lt;/code&gt;, &lt;code&gt;Ds\Map&lt;/code&gt;, &lt;code&gt;Ds\Set&lt;/code&gt;, &lt;code&gt;Ds\Heap&lt;/code&gt; and &lt;code&gt;Ds\Pair&lt;/code&gt; and no &lt;code&gt;Ds\Vector&lt;/code&gt; at all, so the numbers below are from 1.6.0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP 8.4.21, ext-ds 1.6.0
Ds\Vector of N ints (push)                 16,085,160 bytes   16.09 bytes/elem
Ds\Map of N ints (reverse order)           37,748,960 bytes   37.75 bytes/elem
Ds\Map per row inside a Ds\Vector         512,457,608 bytes  512.46 bytes/row
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Ds\Vector&lt;/code&gt; is a contiguous buffer whose capacity isn't tied to powers of two (&lt;a href="https://www.php.net/manual/en/class.ds-vector.php" rel="noopener noreferrer"&gt;the manual says so&lt;/a&gt;) so it lands at 16.09 instead of 16.78. &lt;code&gt;Ds\Map&lt;/code&gt; beats the hash array by about 10%. A &lt;code&gt;Ds\Map&lt;/code&gt; per row is worse than the plain associative array. It's a good API with real algorithmic guarantees and it does give memory back when a structure shrinks (which arrays never do). It isn't a memory fix for rows, and I wouldn't add a C extension to a deployment for 4% on lists.&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%2Fkbb7io234boulmivd527.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%2Fkbb7io234boulmivd527.png" alt="Two bar charts of bytes per element on PHP 8.4.21: a list of one million integers costs 16.0 as SplFixedArray, 16.1 as Ds\Vector, 16.8 as a packed array, 33.6 as a packed array on PHP 8.1 and 41.9 as a reverse-filled hash array; one million five-field rows cost 233 as a class with declared properties, 321 as a packed list, 323 as SplFixedArray(5), 481 as an associative array, 512 as a Ds\Map and 529 as stdClass" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do
&lt;/h2&gt;

&lt;p&gt;Keep lists packed. Append in order, don't fill by index backwards, don't let a string key into something that's supposed to be a list, and call &lt;code&gt;array_values()&lt;/code&gt; on anything that came out of &lt;code&gt;array_filter()&lt;/code&gt; if it's going to live longer than the next line, and remember that &lt;code&gt;array_is_list()&lt;/code&gt; is a fine assertion in a test as long as you know it's checking keys.&lt;/p&gt;

&lt;p&gt;Rows you hold in memory go into a small &lt;code&gt;final&lt;/code&gt; class with promoted typed properties. Half the memory of the associative array, a name and a type for every field, and &lt;code&gt;readonly&lt;/code&gt; costs nothing extra. That's the one change I'd make to most of the code I've read that does &lt;code&gt;fetchAll()&lt;/code&gt; and then walks the result.&lt;/p&gt;

&lt;p&gt;And don't hold a million rows. Here's the materialize-then-loop shape against the same loop over a generator, run in two separate processes so the peak is honest, and the rows are generated in code with no database involved, so it measures the cost of holding them and not of fetching them:&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;function&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Generator&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="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"user&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;@example.com"&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="mf"&gt;1.5&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="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;loadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rows&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="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$row&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="nv"&gt;$all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// materialize: foreach (loadAll(N) as $row) { $sum += $row-&amp;gt;balance; }&lt;/span&gt;
&lt;span class="c1"&gt;// stream:      foreach (rows(N) as $row)    { $sum += $row-&amp;gt;balance; }&lt;/span&gt;
&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;materialize all rows, then loop     peak over start   241,157,928 bytes
stream rows through a generator     peak over start        37,008 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-seven kilobytes instead of 241 megabytes for the same loop over the same million rows. With a real statement the generator is a &lt;code&gt;while ($r = $stmt-&amp;gt;fetch())&lt;/code&gt; that yields one &lt;code&gt;Row&lt;/code&gt; per iteration. There's one caveat and it's a big one on MySQL: &lt;a href="https://www.php.net/manual/en/mysqlinfo.concepts.buffering.php" rel="noopener noreferrer"&gt;queries are buffered by default&lt;/a&gt; so the driver pulls the whole result set into PHP's memory before your first &lt;code&gt;fetch()&lt;/code&gt;, and with mysqlnd that buffer counts against &lt;code&gt;memory_limit&lt;/code&gt;. So a generator over a buffered result set streams the objects and still holds every raw row underneath. The fix is to set &lt;code&gt;Pdo\Mysql::ATTR_USE_BUFFERED_QUERY&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; for the big query (on 8.3 and older it's &lt;code&gt;PDO::MYSQL_ATTR_USE_BUFFERED_QUERY&lt;/code&gt;, which 8.5 deprecates) or to page it by primary key. Laravel's &lt;code&gt;cursor()&lt;/code&gt; is this exact pattern with a nicer name.&lt;/p&gt;

&lt;p&gt;Sixteen bytes a slot is a fair price for a list. What gets expensive is how quietly an array stops being one. 🙂&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>php</category>
      <category>performance</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Stopped Reviewing Code And Started Reviewing Agents</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:46:40 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/i-stopped-reviewing-code-and-started-reviewing-agents-2353</link>
      <guid>https://dev.to/nazar-boyko/i-stopped-reviewing-code-and-started-reviewing-agents-2353</guid>
      <description>&lt;p&gt;I had an incident at work where an AI-powered agent made a change that passed CI and seemed entirely reasonable in the PR. The problem wasn’t just with the code—the issue was that we had never properly verified what the agent was actually allowed to do, which tools it could run, and where its authority ended. And to be honest, this incident completely changed my view of the review process! &lt;/p&gt;

&lt;p&gt;I hadn't really thought about what the agent does behind the scenes. I mean, where exactly it sends the data or if it sends it at all. If a problem came up while checking the code or comparing something in the code, I’d report it as a bug with the agents and ask for it to be fixed! After the agents fixed the bug, I’d test it again and was satisfied with the result, but I didn’t notice what the agents were doing behind the scenes.&lt;/p&gt;

&lt;p&gt;For most of us, code review means just one thing - someone reviews the changes in the code. One person made the changes, another reviewed them, and the reading was the control. Not the tests, not the linter, the reading.&lt;/p&gt;

&lt;p&gt;That stopped being true on a lot of teams and I don't think most of them have said it out loud yet. Picture the pull request an agent opens a few hundred changed lines across a dozen files, a tidy description, green CI. The reviewer opens it and reads the description. Spot-checks the migration and the tests. Approves. Nobody read the few hundred lines. Nobody was ever going to.&lt;/p&gt;

&lt;p&gt;What got reviewed in that PR wasn't the code. It was the agent. Which instructions it ran under and which tools it had. What it was allowed to touch. Whether a human stood between its output and production. Whether the team admits it or not, that's where the review moved. This is about that move, why I think it's the right one, and why it's more dangerous than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody is reading all of it
&lt;/h2&gt;

&lt;p&gt;The diff-based review rested on an assumption so old nobody stated it: writing code is slower than reading it. One person produces a couple of hundred lines in a day and a colleague can read them carefully in twenty minutes, and that ratio held for decades, through every language and framework change, and it's the entire reason "read the diff" worked as a control.&lt;/p&gt;

&lt;p&gt;An agent produces a day's worth of diff in under an hour and a team can run several of them at once. Reading didn't get any faster. So the ratio flipped. The control stopped holding on its own.&lt;/p&gt;

&lt;p&gt;What happens next is the pattern that shows up on almost every team that adopted agents seriously. Review turns into skim. The reviewer reads the PR description (written by the agent), checks that CI is green (on tests written by the agent, against a spec the agent summarized from the ticket it was handed), scrolls past the longest file because it's mostly generated boilerplate and approves. It looks like review. It's a signature.&lt;/p&gt;

&lt;p&gt;I'm not saying that to shame anyone, it's rational. Reading every generated line is not a strategy that scales and pretending it is just means the skim happens in secret. The honest version is to admit the line-by-line read is gone for most changes and ask what replaced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review moved without anyone deciding it would
&lt;/h2&gt;

&lt;p&gt;Here's what replaced it, though nobody wrote a policy. When a team can't inspect every output, it starts inspecting the thing that makes outputs. Nothing new there, it's how anyone hires a contractor. Nobody stands behind an electrician checking every wire nut. They check the license and which walls the electrician is allowed to open and that an inspector signs off before the drywall goes back up. Process trust in place of keystroke trust.&lt;/p&gt;

&lt;p&gt;With agents the process is unusually concrete because most of it is config. Here's the shape of the actual review target on a project running Claude Code (the rule syntax is from the &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions docs&lt;/a&gt;; the specific rules are a made-up example):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&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;"allow"&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;"Read(src/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit(src/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ask"&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;"Bash(git push *)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&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;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(rm *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(curl *)"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file says more about what the agent can do to a codebase than any single PR it opens. Rules are evaluated deny first, then ask, then allow. The first match wins. So a broad deny can't carry an allowlist exception. &lt;code&gt;Bash(npm run *)&lt;/code&gt; matches &lt;code&gt;npm run test --watch&lt;/code&gt; and doesn't match &lt;code&gt;npm install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The file is meant to be checked into version control and shared with the whole team so it shows up in git as a diff. And in my experience it gets about the same attention as a &lt;code&gt;.prettierrc&lt;/code&gt;. That's the drift in one picture: the control moved into a config file and the review process didn't follow it there.&lt;/p&gt;

&lt;p&gt;Honestly, I think this is the most useful reframe available right now. The agent's permissions file, its system prompt and its tool list are the code now, at least in the sense that matters for review: they're the artifacts whose contents decide what ends up in production next week while nobody is looking, and they deserve the slow read the diff used to get.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reviewing the agent looks like on a Tuesday
&lt;/h2&gt;

&lt;p&gt;Not a checklist. Three questions I'd want answered before I trusted a PR from an agent, and in practice they cover most of what matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can it call.&lt;/strong&gt; Every tool an agent can reach is a capability someone granted. A model with a shell plus a database client plus an outbound HTTP tool is a very different colleague from one that can only read files. The sneaky part is that tools arrive through config rather than through code, so they skip the review that code gets, and I wrote up the worst case of this in &lt;a href="https://dev.to/nazar-boyko/your-mcpjson-is-a-backdoor-nobody-reviewed-56b2"&gt;Your .mcp.json Is a Backdoor Nobody Reviewed&lt;/a&gt;: six lines of JSON that download and run a third party's program with your credentials, plus a tool list that lands in the model's context before anyone approves anything, all of it merged under a commit message like "wire up the agent". Reviewing the agent means reading that JSON the way a new dependency gets read because that's what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this might affect?&lt;/strong&gt; Scope of application! Which paths, which branches, which environments. The Claude Code documentation contains an important caveat that, as it turns out, is of great significance: the read and edit restrictions apply to built-in file-handling tools and to file-handling commands that the agent recognizes in Bash, such as &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;sed&lt;/code&gt;.  They don't apply to a Python or Node script the agent writes and then runs, because that script opens files by itself. For that the docs point at the OS-level sandbox, and that's a separate mechanism with its own config block and its own defaults. So a sentence like "it's denied from reading .env" is true only at the layer where somebody configured it, and the review has to ask which layer that is (and whether the agent can reach the other one by writing a five-line script, because usually it can).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who sits between it and production.&lt;/strong&gt; This is the one that saves a team when the first two fail. GitHub's coding agent is a decent reference for what a gate looks like. By default Actions workflows don't run when Copilot pushes to a pull request until a maintainer clicks "Approve and run workflows". If the repo requires approvals, the approval of the person who asked the agent for the change doesn't count toward the number. A second human has to look. That's a deliberate design choice.&lt;/p&gt;

&lt;p&gt;It's also the part that got a little weaker on September 1, 2026 when GitHub shipped an opt-in preview in which Copilot code review can itself approve a pull request and have that approval count toward the required-approvals rule. It's off by default, admins gate it, file paths can be restricted and the approval is dismissed when new commits land, so I don't want to overstate it, but the direction is clear enough: the reviewer of the agent's output can now be another agent, and at that point the only human review left anywhere in the loop is the review of the setup.&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%2Fbal0lwcrwrnpmx5whpyh.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%2Fbal0lwcrwrnpmx5whpyh.png" alt="Diagram titled Where the review sits. Before lane: engineer writes change, reviewer reads the diff (highlighted as the control), production. Now lane: team reviews the agent setup (highlighted, with tools it can call, paths and envs it can touch, gate before production), agent writes change, reviewer samples the diff (dashed), human or CI gate, production. Caption: one review of the setup covers the next hundred PRs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Okay, but a good setup still produced the bug
&lt;/h2&gt;

&lt;p&gt;That's the objection. And it's a valid one. Here's the compromise, to put it simply: when the check shifts from the diff to the agent, the team stops detecting this specific error. The off-by-one in the pagination, the migration that drops a column it should have renamed, the retry wrapped around a call that was never idempotent, the kind of thing a careful reviewer used to catch on a quiet Thursday afternoon with twenty minutes and a coffee, and a permissions file catches none of it. It bounds the blast radius, it doesn't touch correctness.&lt;/p&gt;

&lt;p&gt;So the thing being bought is leverage. One review, a hundred PRs. And the thing being paid is the specific catch. That's a good trade only if the guardrails are real and "real" has a precise meaning here.&lt;/p&gt;

&lt;p&gt;OWASP's entry on &lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;excessive agency&lt;/a&gt; puts it in one line I keep coming back to: implement authorization in downstream systems rather than relying on an LLM to decide if an action is allowed or not. A guardrail that lives in the prompt ("never touch production") is a suggestion. A guardrail that lives in a deny rule, a scoped token, or a branch protection is a control. Reviewing the agent means checking which kind is actually there because the prompt kind fails silently the first time someone pastes a clever issue title into the agent's context (and I'm not sure most teams have tried that against their own setup, for what it's worth it's a ten-minute experiment).&lt;/p&gt;

&lt;p&gt;And the liability didn't move with the review. Whoever merged the PR owns what it does, legally and in the incident channel; I went through the legal half of that in &lt;a href="https://dev.to/nazar-boyko/ai-and-code-ownership-who-is-responsible-for-generated-code-1dnj"&gt;AI and code ownership&lt;/a&gt;. Process trust doesn't transfer responsibility. It only changes where the attention goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff still matters, one level down
&lt;/h2&gt;

&lt;p&gt;I'm not arguing for reading less code. I'm arguing about where the read sits. In the model that's forming, the diff read becomes a sample, not the gate. The migration gets read closely because the blast radius is a table. The forty renamed imports get skimmed because the worst case is a failed build, and the attention that used to go to line 312 of a generated file goes instead to the layer that decides what the next hundred PRs are able to do, meaning the tool list, the scope and the gate, in roughly that order of how often they turn out to be wrong.&lt;/p&gt;

&lt;p&gt;If the agent's diff still needs a slow careful human read every time then the agent isn't saving anyone anything. 🤷‍♂️&lt;/p&gt;

&lt;p&gt;What's uncomfortable is that most teams made this move already, by attrition rather than by decision, in the sense that the skim happened one PR at a time, the approvals kept coming because the builds stayed green, and the permissions file that now decides what the agent can do to the codebase never got a reviewer because it never looked like the kind of file that needed one. If the review moved up a level, the reviewers have to move with it. Otherwise nobody is reviewing anything and the green check is the only one who noticed.&lt;/p&gt;

&lt;p&gt;The diff is still worth reading. The review that protects you now happens one level 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>discuss</category>
      <category>ai</category>
      <category>agents</category>
      <category>codequality</category>
    </item>
    <item>
      <title>Has AI Made You A Lazier Developer? Be Honest.</title>
      <dc:creator>Nazar Boyko</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:31:46 +0000</pubDate>
      <link>https://dev.to/nazar-boyko/has-ai-made-you-a-lazier-developer-be-honest-5ack</link>
      <guid>https://dev.to/nazar-boyko/has-ai-made-you-a-lazier-developer-be-honest-5ack</guid>
      <description>&lt;p&gt;Haven't you ever wondered if this AI vibe coding has made us lazy? Who's been solving problems on LeetCode lately? 😅&lt;/p&gt;

&lt;p&gt;I've noticed that accepting is easier than thinking, by a margin so small that no single accept feels like anything, and it adds up anyway. Part of why it's hard to notice is that it feels faster even when it isn't.&lt;/p&gt;

&lt;p&gt;But I've come to think "lazy" is the right worry aimed at the wrong thing. There are two kinds of lazy and only one of them is a problem.&lt;/p&gt;

&lt;p&gt;I'm going to go into a little background here, because I didn't come up with this, and I didn't reach this conclusion on my own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lazy is why we have compilers
&lt;/h2&gt;

&lt;p&gt;Larry Wall, who created Perl, put laziness first on his list of the &lt;a href="https://thethreevirtues.com/" rel="noopener noreferrer"&gt;three great virtues of a programmer&lt;/a&gt;, and his definition is the whole argument: "the quality that makes you go to great effort to reduce overall energy expenditure." Great effort. Good lazy isn't the absence of work, it's work moved somewhere better, and it's more or less why compilers exist (somebody got tired of writing the same assembly by hand and decided, reasonably, that the machine could do that part) and why every abstraction we lean on all day is really someone's laziness done properly.&lt;/p&gt;

&lt;p&gt;Handing that kind of toil to a model is nothing new. The config I've written a hundred times and the regex I could write but would rather not and the Dockerfile I could recite and the test scaffolding that comes out identical in every project I've ever started: I understand all of it and I'm simply declining to type it again and I feel no guilt about that whatsoever (honestly I'd be more worried about a developer who insisted on typing all of it out by hand in 2026, on principle, one character at a time, while the rest of the team went home). That's not skipping the thinking. That's skipping the typing after the thinking was already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other kind skips the understanding
&lt;/h2&gt;

&lt;p&gt;The second kind of lazy offloads the understanding itself. The model writes the thing and it runs and the tests are green and the PR gets merged and somewhere in that chain there's now a piece of code nobody on the team could explain or fix, and that's the kind that costs, not today but on the day it breaks and somebody on call opens the file at 2am and finds a function nobody can vouch for.&lt;/p&gt;

&lt;p&gt;The smallest illustration I can think of, made up on purpose: say the model writes a regex that validates email addresses at sign-up. If I could have written it and chose not to, that's the first kind of lazy, and if I couldn't have and it's now the thing deciding who gets an account, that's the second kind, and it's the same regex on the same line of the same diff either way. The reviewer can't tell the difference and neither can CI. The only place the two kinds differ is inside my head.&lt;/p&gt;

&lt;p&gt;Okay, but isn't this just the old Stack Overflow copy-paste problem with a faster clipboard? Mostly yes. And I think that's what makes it worse. Stack Overflow made me go find the answer and read a thread of strangers arguing about it (sometimes with the accepted answer being wrong and the better one three comments down with a tenth of the votes) and then adapt it to my code. The friction was doing quiet work. The suggestion just appears in my file already indented and nothing about it asks to be understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Could you rebuild it if it vanished?
&lt;/h2&gt;

&lt;p&gt;I don't have a rulebook for this and I'm suspicious of anyone who does. What I have is one question. Take the last thing the model wrote for you and imagine the file is gone. I don't mean the exact characters, nobody remembers those. Could you sit down and produce something that does the job, and would you know why it works?&lt;/p&gt;

&lt;p&gt;If yes, that was the good kind of lazy and it's worth keeping. If no, it doesn't matter how fast it shipped.&lt;/p&gt;

&lt;p&gt;That's the honest answer to the title, at least for me. It depends on the week. Some weeks every accept is toil I understand. Other weeks I'm not sure, which is a polite way of saying no. The drift never announces itself. It just gets a little easier to press tab each time.&lt;/p&gt;

&lt;p&gt;So I'm asking. I want the real answer, not the one that sounds good. What was the last thing AI wrote for you, and could you rebuild it tomorrow? 😃&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 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>ai</category>
      <category>productivity</category>
      <category>career</category>
    </item>
  </channel>
</rss>
