<?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: Leonid Svyatov</title>
    <description>The latest articles on DEV Community by Leonid Svyatov (@svyatov).</description>
    <link>https://dev.to/svyatov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F122709%2F4d89b6fb-d4ad-4af4-9c12-8cb53789179c.jpeg</url>
      <title>DEV Community: Leonid Svyatov</title>
      <link>https://dev.to/svyatov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/svyatov"/>
    <language>en</language>
    <item>
      <title>Five ways to build a save panel, three of them correct</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:16:46 +0000</pubDate>
      <link>https://dev.to/svyatov/five-ways-to-build-a-save-panel-three-of-them-correct-fla</link>
      <guid>https://dev.to/svyatov/five-ways-to-build-a-save-panel-three-of-them-correct-fla</guid>
      <description>&lt;p&gt;Here is a save panel: a spinner, an error banner, a dirty marker, a confirm dialog, and a closed flag. Five booleans, so thirty-two combinations. Have you ever written down all thirty-two?&lt;/p&gt;

&lt;p&gt;I built it five times to find out what a statechart is actually worth. The first version breaks on the very first event I throw at it. The last version is the longest file of the five.&lt;/p&gt;

&lt;p&gt;Every number I measured comes out of a script, and the command that printed it sits right above the output. The numbers I did not measure are linked instead. Versions: &lt;code&gt;xstate@5.32.5&lt;/code&gt;, bun 1.3.14, TypeScript 7.0.2 and 5.9.3, 2026-08-06.&lt;/p&gt;

&lt;p&gt;One scope note, because it cuts against the tool I am measuring. All five versions are pure functions from a state and an event to a new state: no view layer, no network, no request that actually goes anywhere. So XState's &lt;code&gt;invoke&lt;/code&gt; never appears here, and neither does the cancellation that comes with it (which is a real part of what the library is for, and would have made one of the bugs below its problem rather than mine). I measure the shape of the state, and only that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The spec, as eight rules
&lt;/h2&gt;

&lt;p&gt;If the rules live in prose, every later claim is an opinion. So they live in code, once, and every implementation gets checked against the same eight. Each rule looks at one transition: the screen before, the screen after, the event, and whether the event was a reply to a save that is no longer running.&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="c1"&gt;// R3: a reply from a save that is no longer in flight changes nothing.&lt;/span&gt;
&lt;span class="nx"&gt;R3_stale_reply_ignored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beforeInflight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;afterInflight&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;stale&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;sameView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;beforeInflight&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;afterInflight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a stale reply changed the screen&lt;/span&gt;&lt;span class="dl"&gt;"&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;// R7: once closed, nothing changes.&lt;/span&gt;
&lt;span class="nx"&gt;R7_closed_is_terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;after&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;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;sameView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the screen changed after closing&lt;/span&gt;&lt;span class="dl"&gt;"&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;// R8: a closed panel is not still saving, erroring, or asking.&lt;/span&gt;
&lt;span class="nx"&gt;R8_closing_stops_everything&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;after&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;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spinner&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorBanner&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirmDialog&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the panel closed while still saving, showing an error, or asking&lt;/span&gt;&lt;span class="dl"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule returns a complaint or &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;sameView&lt;/code&gt; just compares the five on-screen signals. The other five rules cover the ordinary things: no spinner and error banner at once, a new save clears the old error, no second save while one is in flight, a successful save leaves the draft clean, and closing with unsaved edits asks first.&lt;/p&gt;

&lt;p&gt;A walker drives all five versions. It starts at the initial state, fires every event, and repeats from everything new it finds. It also invents two kinds of server reply: one for the save the user is watching, and one for a save nobody is watching any more. That second one matters more than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  One: five booleans
&lt;/h2&gt;

&lt;p&gt;This is the shape a panel starts as, and I wrote it as well as I could rather than as badly as I could. It already refuses a second save. It already clears the error banner when a new save starts.&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&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;Ev&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;S&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isClosed&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;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;switch &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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EDIT&lt;/span&gt;&lt;span class="dl"&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isDirty&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSaving&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;s&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSaving&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="na"&gt;hasError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CANCEL&lt;/span&gt;&lt;span class="dl"&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE_OK&lt;/span&gt;&lt;span class="dl"&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hasError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isDirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE_ERR&lt;/span&gt;&lt;span class="dl"&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hasError&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CLOSE&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDirty&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isConfirming&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isClosed&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CONFIRM_DISCARD&lt;/span&gt;&lt;span class="dl"&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isConfirming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isClosed&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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KEEP_EDITING&lt;/span&gt;&lt;span class="dl"&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isConfirming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;It breaks R3 on the first event the walker tries.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;SAVE_ERR&lt;/code&gt; arrives belonging to a save that is not running (the user cancelled, say, and the request landed anyway). The panel shows an error for something nobody is waiting on.&lt;/p&gt;

&lt;p&gt;Look at the code and you cannot fix it. &lt;code&gt;case "SAVE_ERR"&lt;/code&gt; has nothing to compare against. Five booleans can say &lt;em&gt;a save is happening&lt;/em&gt;. They cannot say &lt;em&gt;which one&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That is the point where flags start disagreeing with each other, and it is not the tired &lt;code&gt;isLoading &amp;amp;&amp;amp; isError&lt;/code&gt; complaint. The mainstream answer to that one has been an enum since 2020, when Kent C. Dodds wrote &lt;a href="https://kentcdodds.com/blog/stop-using-isloading-booleans" rel="noopener noreferrer"&gt;Stop using isLoading booleans&lt;/a&gt;. Your reader already agrees. This is a different failure: the flags are individually correct and collectively meaningless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two: name the request
&lt;/h2&gt;

&lt;p&gt;So a reviewer says: give the request a number. 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="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSaving&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;s&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSaving&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="na"&gt;hasError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;saveToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextToken&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;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE_ERR&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saveToken&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;s&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hasError&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="na"&gt;saveToken&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R3 is fixed. R8 still breaks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SAVE&lt;/code&gt;, then &lt;code&gt;CLOSE&lt;/code&gt; on a clean draft. The panel closes with &lt;code&gt;saveToken&lt;/code&gt; still set and &lt;code&gt;isSaving&lt;/code&gt; still true. The screen is gone, and the model thinks a save is in flight behind it.&lt;/p&gt;

&lt;p&gt;The moment you add a token you have stopped using flags and started storing state. The last three versions are all arguments about &lt;em&gt;what shape&lt;/em&gt; that state should take.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three: a union for the save
&lt;/h2&gt;

&lt;p&gt;Here is the version the skeptic asks for, and the skeptic is not a strawman. Here is one, &lt;a href="https://news.ycombinator.com/item?id=35330873" rel="noopener noreferrer"&gt;in 2023&lt;/a&gt;: &lt;em&gt;"I think this is solved more generally with a discriminated union in typescript. A state machine is the wrong solution unless you also need to restrict transitions themselves."&lt;/em&gt;&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Save&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saving&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Save&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;confirming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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 token now lives inside &lt;code&gt;saving&lt;/code&gt; and nowhere else. You cannot read a token off an idle save, because there is nothing to read.&lt;/p&gt;

&lt;p&gt;It passes all eight rules.&lt;/p&gt;

&lt;p&gt;That is worth sitting with, because it is the honest answer to the question everyone asks first. A tagged union and a &lt;code&gt;switch&lt;/code&gt; solve this feature. No library, twenty-four lines of transition logic, done.&lt;/p&gt;

&lt;p&gt;It also answers a question I found asked in April 2026 in an eighty-six comment thread, &lt;a href="https://news.ycombinator.com/item?id=47908833" rel="noopener noreferrer"&gt;with no reply&lt;/a&gt;: &lt;em&gt;since the state chart itself doesn't contain any data, where does the data live, and how do you handle data that's only valid in certain states?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The union's answer is the good one. Data hangs off the state that owns it, so the compiler stops you reading it anywhere else:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;member&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// `user` exists on exactly one member of the union, and this reads it from the whole union.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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 plaintext"&gt;&lt;code&gt;error TS2339: Property 'user' does not exist on type 'State'.
  Property 'user' does not exist on type '{ tag: "guest"; }'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;XState has no equivalent. Its &lt;code&gt;context&lt;/code&gt; is one object typed once for the whole machine, so a field that only makes sense in one state is typed as present in all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS  context.user is reachable, and null, while the machine is in `guest`
      guest context.user = null (the type says string | null in EVERY state)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The union asks the compiler a question the machine cannot be asked. That is a real thing it wins, and none of the articles I read concede it.&lt;/p&gt;

&lt;p&gt;So why keep going?&lt;/p&gt;

&lt;p&gt;Because the union's clean sheet is held up by two lines somebody has to remember to write:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;close&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;S&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;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;closed&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="na"&gt;confirming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closeResetsSave&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// line one&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;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;S&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;Ev&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;S&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closedGuard&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&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;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// line two&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made both switchable so the run can price them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-three-ways.ts
&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;variant                                                 rules broken  which
union reducer  closedGuard=true closeResetsSave=true    0             none
union reducer  closedGuard=true closeResetsSave=false   1             R8
union reducer  closedGuard=false closeResetsSave=true   2             R7,R8
union reducer  closedGuard=false closeResetsSave=false  2             R8,R7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line says &lt;em&gt;once closed, ignore everything&lt;/em&gt;. The other says &lt;em&gt;closing also stops the save&lt;/em&gt;. Neither is enforced by the shape. They are vigilance, written down.&lt;/p&gt;

&lt;p&gt;And the shape allows twenty-four of the thirty-two screens while the code only ever reaches fourteen. Ten configurations exist that nothing produces and nothing checks. &lt;code&gt;closed: true&lt;/code&gt; next to &lt;code&gt;save: { tag: "saving" }&lt;/code&gt; is one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four: a state machine, by hand
&lt;/h2&gt;

&lt;p&gt;A state machine is three things: a set of states, a set of events, and a table saying which event moves you from where to where. That is the whole idea. It predates all of this and needs no library.&lt;/p&gt;

&lt;p&gt;Written out, the states cover every combination of what the save is doing and whether the dialog is up. Here is one row of that table, for the state where a save is running and the dialog is down:&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;savingHidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;EDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;savingHidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dirty&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;CANCEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idleHidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clear&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;SAVE_OK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idleHidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isFresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;SAVE_ERR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;errorHidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isFresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clear&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;CLOSE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;savingConfirming&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isDirty&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;closed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;notDirty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clear&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;Three keys, and I picked the names: &lt;code&gt;to&lt;/code&gt; is the next state, &lt;code&gt;when&lt;/code&gt; is a guard that has to hold, and &lt;code&gt;then&lt;/code&gt; updates the data alongside the state. A list is tried in order, so &lt;code&gt;CLOSE&lt;/code&gt; asks the dialog first and closes only if the draft is clean. The helper named &lt;code&gt;clear&lt;/code&gt; is the one worth reading twice: it does not clear the error, it forgets the token of the save in flight.&lt;/p&gt;

&lt;p&gt;Seven states, twenty-eight arrows, no dependency. It breaks nothing, and it reaches exactly the same fourteen screens as everything that follows.&lt;/p&gt;

&lt;p&gt;Note what fixed R8 here, because it is easy to credit the wrong thing. &lt;code&gt;closed&lt;/code&gt; became a &lt;em&gt;state&lt;/em&gt; instead of a field sitting beside the save. A state cannot be occupied at the same time as &lt;code&gt;savingHidden&lt;/code&gt;, so "closed while saving" stopped being expressible. That is states beating flags. It has nothing to do with statecharts yet.&lt;/p&gt;

&lt;p&gt;So, do you need the library? At this size: no. GitLab reached the same conclusion and wrote &lt;a href="https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/lib/utils/finite_state_machine.js" rel="noopener noreferrer"&gt;about a hundred lines&lt;/a&gt; of finite state machine into their own utils folder in 2021, where it still sits with no hierarchy and no regions. Expo went further: they keep an XState model of their update logic in TypeScript &lt;a href="https://xstate-in-the-wild.transistor.fm/episodes/xstate-at-expo-with-doug-lowder/transcript" rel="noopener noreferrer"&gt;as a reference document&lt;/a&gt; and ship the real thing hand-written in Kotlin and Swift.&lt;/p&gt;

&lt;p&gt;Look again at that table, though. &lt;code&gt;EDIT&lt;/code&gt; appears in every single row. &lt;code&gt;savingConfirming&lt;/code&gt; exists only because saving and confirming can happen together. Both of those are the smell.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two ideas
&lt;/h2&gt;

&lt;p&gt;David Harel worked the two ideas out over 1982 and 1983, and he says what they are in one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Besides a host of other constructs, the two main ideas in statecharts are hierarchy and orthogonality, and these can be intermixed on all levels.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Think about the light switches in a house.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hierarchy&lt;/strong&gt; is the breaker. The kitchen has its own switch, and so does the porch, but there is one breaker upstream of both, and throwing it turns off every room at once. Nobody wires "and also go dark when the breaker trips" into each individual lamp. That is a state containing states: being in &lt;code&gt;open&lt;/code&gt; means being in exactly one of its children, and anything true of &lt;code&gt;open&lt;/code&gt; is true of all of them. It is XOR, and it is why &lt;code&gt;EDIT&lt;/code&gt; is written once on the parent instead of six times on the leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orthogonality&lt;/strong&gt; is the two switches. The kitchen light does not know or care whether the porch light is on. You could label all four combinations on a single dial (kitchen-on-porch-off, kitchen-off-porch-on, and so on) and it would work, and you would relabel the whole dial the day somebody adds a hallway. Or you keep two switches. That is what the panel's save and its dialog are: one list of seven names becomes two lists of three and two. It is AND, and it is why &lt;code&gt;savingConfirming&lt;/code&gt; stops needing a name.&lt;/p&gt;

&lt;p&gt;The four combinations still exist in the house. You just stopped writing them down.&lt;/p&gt;

&lt;p&gt;Harel is careful about what orthogonality is not, and every tutorial I read got this wrong:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Orthogonal state-components in statecharts are not the same as concurrent or parallel components of the system being specified ... but simply to help structure its state space.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So: not threads, and nothing runs at the same time. Two switches on one wall.&lt;/p&gt;

&lt;p&gt;Here is the panel with both ideas applied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;draft
│
├── open              two regions, both live at once
│   │                 on EDIT: mark dirty   &amp;lt;- written once, works from every leaf below
│   │
│   ├── save                          ├── dialog
│   │     idle                        │     hidden
│   │       SAVE          -&amp;gt; saving   │       CLOSE [dirty] -&amp;gt; confirming
│   │     saving                      │       CLOSE [clean] -&amp;gt; closed
│   │       SAVE_OK [fresh] -&amp;gt; idle   │     confirming
│   │       SAVE_ERR [fresh] -&amp;gt; error │       KEEP_EDITING    -&amp;gt; hidden
│   │       CANCEL        -&amp;gt; idle     │       CONFIRM_DISCARD -&amp;gt; closed
│   │     error                       │
│   │       SAVE          -&amp;gt; saving   │
│
└── closed (final)     leaving `open` exits BOTH regions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Five: the statechart
&lt;/h2&gt;

&lt;p&gt;Same spec, same eight rules. The guards and actions get declared first, then the shape.&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;machine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ev&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;guards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;isDirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;context&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isFresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE_OK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE_ERR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;startSave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;context&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;nextToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;context&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextToken&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="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;forgetSave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&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="na"&gt;markSaved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&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="na"&gt;markDirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dirty&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="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isFresh&lt;/code&gt; is the same token check from version two, named once and reused by both replies.&lt;/p&gt;

&lt;p&gt;Then the shape. &lt;code&gt;open&lt;/code&gt; is the parent, &lt;code&gt;type: "parallel"&lt;/code&gt; makes its children regions rather than alternatives, and &lt;code&gt;EDIT&lt;/code&gt; sits on the parent:&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="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;createMachine&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&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="na"&gt;nextToken&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="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;parallel&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// Hierarchy: EDIT is written once here and covers every leaf below it.&lt;/span&gt;
      &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;EDIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;markDirty&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="na"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Orthogonality: these two regions are both live and neither knows about the other.&lt;/span&gt;
        &lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;SAVE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saving&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startSave&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="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;saving&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;SAVE_OK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isFresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;markSaved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="na"&gt;SAVE_ERR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isFresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;forgetSave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="na"&gt;CANCEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;forgetSave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="c1"&gt;// No handler for SAVE: a second save cannot start while one is in flight.&lt;/span&gt;
              &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;SAVE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saving&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;startSave&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="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;Read the &lt;code&gt;saving&lt;/code&gt; block again. The rule against a second save while one is in flight is expressed by leaving something out.&lt;/p&gt;

&lt;p&gt;The dialog region never mentions the save. Two things in it need a word. &lt;code&gt;#draft.closed&lt;/code&gt; is an absolute target: the &lt;code&gt;#&lt;/code&gt; means "start from the machine's id", which you need here because &lt;code&gt;closed&lt;/code&gt; is not a sibling of &lt;code&gt;hidden&lt;/code&gt;. And a list of transitions is tried in order, so the guarded entry gets first refusal and the unguarded one is the fallback:&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;dialog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nl"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nl"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;CLOSE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isDirty&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confirming&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="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#draft.closed&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="p"&gt;},&lt;/span&gt;
            &lt;span class="nx"&gt;confirming&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nl"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;KEEP_EDITING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;CONFIRM_DISCARD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#draft.closed&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="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="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// Leaving `open` exits both regions. Nothing has to remember to stop the save.&lt;/span&gt;
    &lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;final&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;closed&lt;/code&gt; is a sibling of &lt;code&gt;open&lt;/code&gt;, one level up. The save region is never asked to stop. It stops because it no longer exists. (That is the breaker again: nothing tells the kitchen light to go off.)&lt;/p&gt;

&lt;p&gt;That is the union reducer's &lt;code&gt;closeResetsSave&lt;/code&gt; line, deleted and replaced by a shape.&lt;/p&gt;

&lt;p&gt;Now run all five:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-three-ways.ts
&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;SPEC: a draft editor's save panel. 8 rules, 8 events, 5 on-screen signals (2^5 = 32 screens).

implementation                  screens allowed  screens reached  never checked  rules broken
booleans                        32 of 32         21               11             R3,R8
booleans + request token        32 of 32         21               11             R8
union reducer                   24 of 32         14               10             none
flat machine, no library        14 of 32         14               0              none
statechart (XState v5)          14 of 32         14               0              none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;On this spec the statechart wins nothing on correctness.&lt;/strong&gt; The union reducer catches every rule it catches, and so does a flat machine with no dependency at all. All three reach an identical set of fourteen screens, and the run fails on purpose if they ever diverge.&lt;/p&gt;

&lt;p&gt;Do not read too much into the "screens allowed" column, though. It flatters machines by construction (a machine's set of possible states &lt;em&gt;is&lt;/em&gt; its list of declared states, so its slack is structurally zero), which means it separates machines from flags and tells you nothing about statecharts against flat machines.&lt;/p&gt;

&lt;p&gt;And the walker is a search, not a proof: it treats two internal states that look identical on screen as one node. So a second check expands every path to a fixed depth, with no de-duplication at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/probe-exhaustive.ts
&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;The shipped walker de-dups by (screen, save in flight?). This one does not de-dup at all:
every event path to depth 6, on all three implementations that broke no rule.

  union reducer              paths= 352971  violations=none
  flat machine, no library   paths= 374841  violations=none
  statechart (XState v5)     paths= 374841  violations=none

PASS: the de-duplicating walker hid nothing. Same verdict under the exhaustive one.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six events deep is a bound, not a proof. It is a much wider net than the first walk, and it is still a net.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what did that buy?
&lt;/h2&gt;

&lt;p&gt;Arrows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-explosion.ts
&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;Same spec, same behaviour, two shapes:

  flat machine     7 states, 28 arrows written by hand
  statechart       6 leaf states (10 nodes with parents), 10 arrows written
  ratio            2.8x fewer arrows in the statechart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I do not want you to take that on faith, and I did not either. The script flattens the statechart mechanically: cross-product the two regions, copy each leaf's arrows into every combination it appears in, add the parent's arrows once per combination. It gets seven states and twenty-eight arrows. That is the hand-written flat machine, exactly, and the script exits non-zero if it ever stops matching.&lt;/p&gt;

&lt;p&gt;Which means I can add concerns and let it do the arithmetic. Each new concern below is a two-state toggle with one arrow per state. An "offline / online" indicator. A "compact / expanded" view.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  extra   flat states   flat arrows   chart leaves   chart arrows   union: allowed / unchecked
  0       7             28            6              10             24 / 10
  1       13            68            8              12             48 / 20
  2       25            160           10             14             96 / 40
  3       49            368           12             16             192 / 80
  4       97            832           14             18             384 / 160
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four independent toggles is not an exotic feature (a save panel that also knows about the network, the layout, whether the document is shared, and whether you are on a phone). Ninety-seven states and eight hundred arrows, or fourteen states and eighteen arrows.&lt;/p&gt;

&lt;p&gt;But read the last column before you conclude anything, because &lt;strong&gt;the union reducer is not on the exploding side of this table.&lt;/strong&gt; A toggle costs it one more boolean field, not a cross product of names. What grows for the union is the space nothing checks: at four toggles its shape admits 384 configurations, 160 of which nothing reaches and no rule inspects. The statechart's figure there is zero at every row, because a machine's shape is its list of states.&lt;/p&gt;

&lt;p&gt;So the choice the table actually offers is not statechart or explosion. It is: write the cross product out by hand, or keep a boolean field per concern and accept that most of what your type allows is never looked at.&lt;/p&gt;

&lt;p&gt;And here is the caveat that keeps the rest of it honest: &lt;strong&gt;both machine columns describe the same program.&lt;/strong&gt; The configuration space did not shrink. Three regions of two states each is eight configurations either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-orthogonality.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Three&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;orthogonal&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;regions,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;two&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;states&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;each:&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;written&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;down&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;leaf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;states,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;arrows&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;reachable&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;configurations&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;formalism&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;^&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;initial&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;value&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"a1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"b1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"c1"&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;Orthogonality does not make your program simpler. It makes the description shorter, and the description is the thing a person has to hold in their head.&lt;/p&gt;

&lt;p&gt;One more thing before I let the statechart off. Back in section three I knocked out the reducer's two hand-written lines to show they were vigilance rather than structure. It would be unfair to stop there, so I ran the same sweep on the statechart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;statechart     isFresh=true                             0             none
statechart     isFresh=false                            1             R3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn off the token guard and the statechart breaks R3, exactly the way the flags version did on the very first event. Neither the hierarchy nor the two regions produce that guard. It is a line I wrote by hand, same as the reducer's two, and nothing complains when it goes missing.&lt;/p&gt;

&lt;p&gt;So the score is honest: the shape bought R7 and R8, in every version that made &lt;code&gt;closed&lt;/code&gt; a state. It did not buy R3, in any version. Somebody has to write that one down.&lt;/p&gt;

&lt;p&gt;That is the whole trade. A statechart is a notation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Harel ranked state explosion second
&lt;/h2&gt;

&lt;p&gt;Which is exactly what it was built to be, and the story is not the one usually told.&lt;/p&gt;

&lt;p&gt;In December 1982 Harel took a consulting job: one day a week, Thursdays, at Israel Aircraft Industries, on the avionics team of the Lavi fighter. He describes it himself in a &lt;a href="https://www.weizmann.ac.il/math/harel/sites/math.harel/files/users/user50/Statecharts.History.pdf" rel="noopener noreferrer"&gt;2007 account&lt;/a&gt; that he labels highly personal and subjective, so read it as testimony rather than measurement.&lt;/p&gt;

&lt;p&gt;The specification was two volumes of structured English, around a thousand pages each. He asked what he thought was a simple question: what happens when you press this button on the stick?&lt;/p&gt;

&lt;p&gt;They answered it. They opened volume B, found the clause, and read it to him. So he added a condition, and they found a different clause in the other volume.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Q1   what happens when you press this button?        volume B, clause 19.11.6.10
Q2   ... even with an infra-red missile locked on a ground target?  volume A, clause 6.12.3.7
Q5   ...                                             the engineers phone the customer
Q8   ...                                             "even those people often did not have an answer"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Those people" is the Air Force team that owned the requirements. The team knew exactly what algorithm the radar used to measure distance to a target, and could not say what happens when you press this button under all possible circumstances.&lt;/p&gt;

&lt;p&gt;Now the part everyone gets backwards. Ask why statecharts exist and you will be told: state explosion. Harel puts that second.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;not only because of the number of states ... even more important seemed to be the pragmatic point of view&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The more important half, in his words, is that a flat list of states "has no means for modularity, hiding of information, clustering, and separation of concerns". He was complaining that you could not organize the states, not that there were too many of them.&lt;/p&gt;

&lt;p&gt;That is a notation problem described in the vocabulary of software design, and it is why the growth table above is the &lt;em&gt;second&lt;/em&gt; reason to care rather than the first.&lt;/p&gt;

&lt;p&gt;The diagrams came last, and by accident. Harel first wrote a textual language, hierarchical "statocols" (structured state protocols, and the name did not survive either), and doodled pictures beside it to explain it. The engineers understood the doodles better than the language. Within weeks he was asking whether the pictures could replace the text rather than illustrate it.&lt;/p&gt;

&lt;p&gt;The paper was rejected for about two and a half years. One referee wrote that "the topic is good only for a very narrow audience". &lt;a href="https://api.crossref.org/works/10.1016/0167-6423%2887%2990035-9" rel="noopener noreferrer"&gt;Crossref&lt;/a&gt; counted 4,392 citations of it on the day I looked.&lt;/p&gt;

&lt;p&gt;And the idea travelled a long way past the fighter jet. &lt;a href="https://www.w3.org/TR/scxml/" rel="noopener noreferrer"&gt;SCXML&lt;/a&gt; became a W3C Recommendation in September 2015, with a normative interpretation algorithm and a conformance section, explicitly "based on CCXML and Harel State Tables". &lt;a href="https://doc.qt.io/qt-6/qtscxml-index.html" rel="noopener noreferrer"&gt;Qt ships an engine for it&lt;/a&gt; that generates C++. Do not read that as portability for the code in this post, though: XState says it is "inspired by" SCXML and claims no conformance, so a machine written here does not move to another engine. UML state machines are, in the OMG's lineage, an object-based variant of Harel's statechart. NASA JPL &lt;a href="https://www.state-machine.com/doc/Benowitz2006.pdf" rel="noopener noreferrer"&gt;generated flight code from them&lt;/a&gt; for the Space Interferometer Mission, and Deep Space 1 was their first mission to do it, though the public JPL autocoder repository is a ground tool whose header reads "(NO FLIGHT CODE ALLOWED)".&lt;/p&gt;

&lt;p&gt;One tool that sells the notation, meanwhile, refuses the word. &lt;a href="https://www.mathworks.com/products/stateflow.html" rel="noopener noreferrer"&gt;MathWorks Stateflow&lt;/a&gt; generates C, C++, VHDL and Verilog, and its customer stories name surgical device control and antenna stabilization. Its product page never once says "Harel" or "statechart". It says state transition diagrams and truth tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting a machine, and the types
&lt;/h2&gt;

&lt;p&gt;Everything above is one small machine. The complaint I kept running into starts with the second one: people try to split a definition into reusable pieces and the types fight back.&lt;/p&gt;

&lt;p&gt;That complaint dates from XState v4, so I re-ran it against what ships today, on both current TypeScript majors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-ts-splitting.ts
&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;xstate 5.32.5, tsc --strict, TypeScript 7.0.2 and 5.9.3

                                                            7.0.2           5.9.3
A  shared sub-state object, spread into two machines        FAILS TS2345    FAILS TS2345
A2 the same chunk, with `as const`                          FAILS TS2345    FAILS TS2345
A3 the same chunk, typed from the builder's config type     compiles        compiles
B  split into a child machine and invoked                   compiles        compiles
C  persist a snapshot and restore it, no `any`              compiles        compiles
D  config chunk in another module, guard named in setup()   FAILS TS2345    FAILS TS2345
D2 the same chunk, with `as const`                          compiles        compiles
D3 the same chunk, typed from the builder's config type     compiles        compiles
E  the documented way: setup(...).createStateConfig(...)    compiles        compiles

Both compilers agree on every case.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive split does still fail. Plain TypeScript causes it, though, not statecharts: pull a chunk into a bare object literal and &lt;code&gt;"isHigh"&lt;/code&gt; widens from a literal to &lt;code&gt;string&lt;/code&gt;, and the config type wanted the literal.&lt;/p&gt;

&lt;p&gt;The fix is documented and I had missed it. &lt;code&gt;setup(...).createStateConfig(...)&lt;/code&gt; has shipped since 5.21.0 and it compiles clean with the guard still named as a string, no cast:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ev&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;guards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;isHigh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;context&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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&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;const&lt;/span&gt; &lt;span class="nx"&gt;states&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createStateConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;BUMP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isHigh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&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="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&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="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;high&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;Do not reach for &lt;code&gt;as const&lt;/code&gt; instead. It rescues case D2 and stops working the moment the chunk contains an inline &lt;code&gt;assign(...)&lt;/code&gt;, which is case A2, still failing above.&lt;/p&gt;

&lt;p&gt;Harel, incidentally, would not have started here at all. "No one is encouraged to specify a single statechart for an entire system," he writes, footnoting that his own famous digital-watch example did exactly that for presentation reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill
&lt;/h2&gt;

&lt;p&gt;The statechart is the longest of the five files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-cost.ts
&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;lines of code, blank and comment lines dropped:

                       whole file   transition logic only
  booleans             62           23
  booleans + token     68           25
  union reducer        70           24
  flat machine         94           40
  statechart           116          57
  (whole-file counts include the adapter each version needs to join the test harness)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the right column (the left one counts each version's glue into my test harness, which is my problem and not yours). Fifty-seven lines of transition logic against the union reducer's twenty-four, for identical behaviour. That gap is real, and I did not measure where or whether it closes, so I will not tell you that it does.&lt;/p&gt;

&lt;p&gt;Then the bytes. And here I got a surprise, because I assumed the weight was the runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-cost.ts   &lt;span class="c"&gt;# same script, second half of its output&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;  nothing (baseline)   min     24 B   gzip    44 B   (esbuild    44 B)
  xstate               min  42362 B   gzip 13965 B   (esbuild 12697 B)
    the builder only   min  42333 B   gzip 13947 B   (esbuild 12688 B)
    the runtime only   min   9937 B   gzip  3452 B   (esbuild  3088 B)
    pure transitions   min  10379 B   gzip  3586 B   (esbuild  3225 B)
  @xstate/store        min   7330 B   gzip  2854 B   (esbuild  2789 B)

  the two bundlers differ by at most 11% on any real row.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing that drives a machine is three and a half kilobytes. The thing that &lt;em&gt;defines&lt;/em&gt; one is fourteen. Of the 13,965 bytes you actually ship, the runtime is 3,452, so three quarters of it is the notation. (Do not add the builder and the runtime together: they overlap heavily, so the two numbers are not a split of the total. And never quote a kilobyte figure without saying which bundler produced it, which is what that eleven percent is doing there.)&lt;/p&gt;

&lt;p&gt;While measuring that, I noticed &lt;code&gt;@xstate/store&lt;/code&gt; advertises itself as "&lt;strong&gt;Extremely small&lt;/strong&gt;: less than 1kb minified/gzipped". Today it measures 2,854 bytes gzipped. I installed version 1.0.0 to check, and there the claim was true: 533 bytes. The package grew and the line did not.&lt;/p&gt;

&lt;p&gt;More costs, none of which the field prices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you learn expires.&lt;/strong&gt; XState has shipped three incompatible ways to write a machine in three majors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2018-10-29  v4         Machine(...)         cond:     interpret(...)
2023-12-01  v5         createMachine(...)   guard:    createActor(...)   setup({ types })
2026-06-20  v6 alpha   the v5 action and guard creators removed; schemas replace types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The v4-to-v5 &lt;a href="https://stately.ai/docs/migration" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; runs 2,237 lines with 47 breaking changes, and some change meaning rather than spelling: transitions became internal by default, and child state nodes are always re-entered. A chart drawn against v4 behaves differently under v5.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://github.com/statelyai/xstate/releases/tag/xstate%406.0.0-alpha.1" rel="noopener noreferrer"&gt;v6 alpha&lt;/a&gt; arrived in June 2026 and deleted the vocabulary I used above: &lt;code&gt;assign&lt;/code&gt;, &lt;code&gt;raise&lt;/code&gt;, &lt;code&gt;sendTo&lt;/code&gt;, &lt;code&gt;enqueueActions&lt;/code&gt;, and the &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt; and &lt;code&gt;not&lt;/code&gt; guards. That is why every snippet here is stamped 5.32.5, and why you should check the date on any XState article before you copy from it, this one included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learning it is the cost everyone names first.&lt;/strong&gt; I cannot measure this and neither can anyone else, so here it is as testimony: one practitioner puts the curve "more similar to rx-js or fp-ts than a state manager", another says "building state machines is an art in and of itself and I had to throw away my first implementation". Take those as reports rather than as a number, but notice that three of the four introductions I read name the learning curve as the top cost and then move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The diagram costs money, or is abandoned.&lt;/strong&gt; The payoff people rate highest is showing the chart to someone who does not read code. The old visualizer repository is &lt;a href="https://github.com/statelyai/xstate-viz" rel="noopener noreferrer"&gt;archived&lt;/a&gt; and its README now reads "XState (Legacy) Visualizer". The VS Code extension was last updated in January 2024 and the docs still say it is not compatible with v5, which shipped in December 2023. The current editor is a product: free if your projects are public, &lt;a href="https://stately.ai/pricing" rel="noopener noreferrer"&gt;$33 a month&lt;/a&gt; billed annually or $39 monthly if they are not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other advertised payoff is also between homes.&lt;/strong&gt; Generating test paths from the machine is one of the benefits the introductions I read put near the top. I hand-rolled the exhaustive walker above; XState ships that idea too, and its documentation page currently says the utilities have moved into &lt;code&gt;@xstate/graph&lt;/code&gt; and that the docs for them are "coming soon", while what is written is for a beta of the package they replaced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That payoff is also contested.&lt;/strong&gt; A former gadget.dev engineer, in the same 2026 thread, reports the opposite result: "we started with hierarchical statecharts as a way for users to specify their behaviour ... No matter what we did, users just found it incredibly confusing." He also reduced "1000+ lines of very hard to read and extend statechart code to a few hundred lines of imperative code" and found that easier than fixing the bugs hiding in it. He is not an opponent ("I love the idea of statecharts, I just have scars from dealing with them"). One person's experience, self-reported, and worth more than a benefits page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some things it will not catch.&lt;/strong&gt; Send an event the current state does not handle and XState swallows it: the actor stays where it was, says nothing, and carries on. A typo in a target &lt;em&gt;does&lt;/em&gt; throw, early, at &lt;code&gt;createMachine&lt;/code&gt; time before any actor exists, which beats a hand-rolled switch where a mistyped string waits until runtime. But TypeScript will not catch that typo. And this machine constructs without complaint and then never finishes starting:&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="nf"&gt;createMachine&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;always&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;b&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;always&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs say XState "will help guard against most infinite loop scenarios" and give a rule: if a target is declared, it should differ from the current state node. Both of those targets do differ. It hangs anyway.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run src/verify-behaviour.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;typo'd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;throws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;createMachine(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;before&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;any&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;actor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exists&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;Invalid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;transition&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;definition&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'(machine).a':&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Child&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'typoState'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;does&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'(machine)'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;unhandled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;changes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;nothing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;warns&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;nothing&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;a,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;can()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;warnings&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;manipulation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;check:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;actor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DOES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;move&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;it&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;handles&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;b&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;targetless&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;self-transition&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;preserves&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;active&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;child&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"process"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"step3"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;self-transition&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;explicit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;resets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;it&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;initial&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;child&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"process"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"step1"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;an&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;always/always&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cycle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;constructs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;then&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;never&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;finishes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;starting&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;printed:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"constructed"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;killed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;s&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third line is there because the second is a claim about an absence, and an absence proves nothing unless you show the thing was listening.&lt;/p&gt;

&lt;p&gt;Lines four and five are one character apart: an explicit target re-enters the state even when it is the state you are already in.&lt;/p&gt;

&lt;p&gt;A hand-written transition table does catch the bogus target that XState's types miss, but only if you tie its targets to the state union:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Table&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="nx"&gt;S&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tag&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]?:&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tag&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the typo into a free-standing literal type instead, with no tie back to &lt;code&gt;State&lt;/code&gt;, and it compiles clean. The check comes from that line, not from having used a union.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to reach for one
&lt;/h2&gt;

&lt;p&gt;The test is mechanical, and you can run it on your own code in about a minute: &lt;strong&gt;count your nested states and your parallel regions. If both are zero, you have written a state machine and paid for a statechart.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nested states?      no  ─┐
parallel regions?   no   ├─▶  you have a state machine. Write the switch.
`after` timers?     no   │
history states?     no  ─┘

any of them yes    ─────▶  you are using the notation. Keep it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timers and history states are on that list because XState hands you both as primitives, &lt;code&gt;after&lt;/code&gt; being the one-line version of a delayed transition. You can write either by hand, so this is a question of what you are getting for the dependency rather than of what is possible. And it tests the library, not the idea: a flat machine is a fine thing to have, it just does not need fourteen kilobytes to be one.&lt;/p&gt;

&lt;p&gt;In October 2025 the mapping library lonboard opened an &lt;a href="https://github.com/developmentseed/lonboard/issues/916" rel="noopener noreferrer"&gt;issue&lt;/a&gt; saying "The current XState implementation is overkill for our simple UI interactions". The &lt;a href="https://github.com/developmentseed/lonboard/pull/1042" rel="noopener noreferrer"&gt;migration to Zustand&lt;/a&gt; merged that December. The machine they deleted, &lt;a href="https://raw.githubusercontent.com/developmentseed/lonboard/0577b0722ccbcffda86f1a4ff4aedd01394e258c/src/xstate/machine.ts" rel="noopener noreferrer"&gt;pinned at the commit before the merge&lt;/a&gt;, was 180 lines, three top-level states, six event types, and zero nested-states blocks.&lt;/p&gt;

&lt;p&gt;Flat. It was a state machine the whole time.&lt;/p&gt;

&lt;p&gt;The honest code delta is small: 375 lines of application source removed, 283 added, so about ninety lines net. The pull request header says +1425/-488, but roughly eleven hundred of that is new Playwright tests, and quoting it as a code-size result would be dishonest. Their own stated goal was not fewer lines. It was to "Lower barrier to entry for JS developers".&lt;/p&gt;

&lt;p&gt;The vendor says something close to the same thing, which is stronger evidence than a skeptic saying it, because the vendor has every reason not to. The XState README tells you to start with &lt;code&gt;@xstate/store&lt;/code&gt; "if you just need a store" and graduate later. When they announced that store in 2024 they wrote that teams were using XState "for simple data updates, where using full state machines may be overkill". The author's own rule, from an April 2026 thread: statecharts "are most useful when you have behavior where the answer to 'what happens next?' depends on both the current state &amp;amp; the event".&lt;/p&gt;

&lt;p&gt;Leaving is not free, either. In a 2024 &lt;a href="https://github.com/statelyai/xstate/discussions/4708" rel="noopener noreferrer"&gt;discussion&lt;/a&gt; titled "Need suggestions on migrating away from XState", the poster opened with "XState is just way too sophisticated for our needs" and, ten months later, wrote: "We haven't done the migration because this is a big undertaking and we don't have the resources for it at the moment."&lt;/p&gt;

&lt;p&gt;And one widely shipped engine threw one of the two ideas away on purpose. &lt;a href="https://github.com/chakra-ui/zag" rel="noopener noreferrer"&gt;Zag.js&lt;/a&gt;, which drives the components in Chakra UI and Ark UI, says "We don't follow the SCXML specifications" and instructs its own contributors to "Avoid using complex machine concepts like spawn, nested states, etc." Finite states, yes. Hierarchy, no.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else is there
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A tagged union and a &lt;code&gt;switch&lt;/code&gt;.&lt;/strong&gt; Already shown. For a flat model it is the right answer, it is shorter, and it types per-state data better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zustand, Redux, &lt;code&gt;@xstate/store&lt;/code&gt;.&lt;/strong&gt; All stores. Worth knowing that &lt;code&gt;@xstate/store&lt;/code&gt; is not a small statechart: the words &lt;code&gt;states&lt;/code&gt;, &lt;code&gt;initial&lt;/code&gt;, &lt;code&gt;parallel&lt;/code&gt;, &lt;code&gt;guard&lt;/code&gt;, &lt;code&gt;entry&lt;/code&gt;, &lt;code&gt;exit&lt;/code&gt; and &lt;code&gt;history&lt;/code&gt; appear nowhere in its type surface. It is context and events. And &lt;code&gt;@xstate/fsm&lt;/code&gt;, the small official state machine, is deprecated, so the ladder now goes from nothing to a store with no states to the full fourteen kilobytes, with nothing in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Behavior trees.&lt;/strong&gt; The competitor that won an entire industry. Similar to hierarchical state machines, except the building block is a &lt;em&gt;task&lt;/em&gt; rather than a state, which turns out to suit game AI better. Halo, BioShock, Spore; Unity and Unreal both support them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLA+ and friends.&lt;/strong&gt; A different altitude: you model the algorithm and check the model, rather than running it. The AWS team's &lt;a href="https://lamport.azurewebsites.net/tla/formal-methods-amazon.pdf" rel="noopener noreferrer"&gt;report&lt;/a&gt; also states the ceiling that applies to everything in this post, statecharts included: "TLA+ cannot verify code, only algorithms. Code can have bugs that the spec does not have."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Durable workflow engines.&lt;/strong&gt; AWS &lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html" rel="noopener noreferrer"&gt;Step Functions&lt;/a&gt; calls its workflows state machines outright, and it does the thing a statechart in a browser tab cannot: a run stays alive for up to a year, and you are billed per transition. XState hands you a JSON snapshot and restores from it, which is not the same promise, because you own the storage and the uptime.&lt;/p&gt;

&lt;p&gt;One piece of prior art deserves naming, because the shape of this post is not new. In July 2020 Matt Pocock built &lt;a href="https://dev.to/mattpocockuk/usestate-vs-usereducer-vs-xstate-part-1-modals-569e"&gt;a modal three ways&lt;/a&gt;, counted the refactors each version needed under a changed requirement, and got 4, 4 and 2. Two things about it. The new requirement was a timed transition, which is the one case XState answers with a one-line primitive, so the setup favours the tool. And the promised part two never came. What I did not find anywhere is a discriminated union of &lt;em&gt;states&lt;/em&gt; entered as a real contender, or anybody reporting a count instead of a verdict.&lt;/p&gt;




&lt;p&gt;So: count your nested states. Count your regions. If you have neither, write the switch, and know that you are in good company. If you have both, and you are writing &lt;code&gt;savingConfirming&lt;/code&gt; by hand, you already have a statechart. You are just spelling it out one combination at a time.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your agent writes Python. The Ruby rule cuts that by a third.</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Thu, 06 Aug 2026 12:33:48 +0000</pubDate>
      <link>https://dev.to/svyatov/your-agent-writes-python-the-ruby-rule-cuts-that-by-a-third-476</link>
      <guid>https://dev.to/svyatov/your-agent-writes-python-the-ruby-rule-cuts-that-by-a-third-476</guid>
      <description>&lt;p&gt;Lucian Ghinda &lt;a href="https://allaboutcoding.ghinda.com/write-agent-scripts-in-ruby/" rel="noopener noreferrer"&gt;published a post&lt;/a&gt; arguing you should tell your coding agent to write its throwaway scripts in Ruby. Here is the block he tells you to paste into your agent's instruction file, in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Scripts&lt;/span&gt;

Write throwaway and utility scripts (data munging, one-off migrations,
file renames, glue code) in Ruby, even in projects written in another
language. If it needs a pipe, a loop, a conditional, or more than one
line, it is a script: write it in Ruby, not Python, Node, or bash.
Single self-contained commands (&lt;span class="sb"&gt;`grep`&lt;/span&gt;, &lt;span class="sb"&gt;`git status`&lt;/span&gt;) are fine as-is.

Use only the Ruby standard library. If a gem would clearly save
significant effort, stop and ask before using it.

Put temporary scripts in a scratch or temp directory, not the repo
root, and delete them when done unless asked to keep them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pasted it into my global &lt;code&gt;CLAUDE.md&lt;/code&gt; the same evening. His argument is about review: he reads Ruby daily, so when the agent writes Ruby he stays a reviewer instead of nodding at a diff. He gives three reasons and not one of them is cost. So I went looking for the number he left out.&lt;/p&gt;

&lt;p&gt;"Does the rule save tokens" only means something against what the agent writes otherwise, so the first thing I had to do was take the block back out of my global config. An agent that already carries the rule cannot tell you what it would do without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring an agent without your config in the room
&lt;/h2&gt;

&lt;p&gt;Every arm below runs through &lt;code&gt;claude --safe-mode&lt;/code&gt; on Claude Opus 5, which loads no &lt;code&gt;CLAUDE.md&lt;/code&gt;, no skills, no plugins and no hooks. Two arms deliberately skip that flag, and I name them where they appear: they are the ones that measure what my own setup does to the result. It is worth knowing that my global config alone still carries a line telling the agent to write the minimum code that does the job, and another preferring bun over node.&lt;/p&gt;

&lt;p&gt;Four tasks, one for each kind of script the rule names: munge a log, rename a key across a tree of config files, renumber a pile of screenshots, turn one CSV into another. I took them from his list rather than inventing my own, so I could not quietly pick ground that suits Ruby.&lt;/p&gt;

&lt;p&gt;Three things about the setup are worth knowing before any number lands. All three are choices I made, and all three are arguable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No arm pastes the rule itself. Each names a language and the standard-library constraint directly, which is what the rule produces rather than its own text.&lt;/li&gt;
&lt;li&gt;The bash arm may not use &lt;code&gt;jq&lt;/code&gt;, since the rule says standard library only and &lt;code&gt;jq&lt;/code&gt; is a separate install.&lt;/li&gt;
&lt;li&gt;The Ruby arm alone was told one of its brevity idioms is available. That is a thumb on the scale for Ruby, worth about 20 of its 1660 bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing is counted until it is proven equivalent. Every implementation of a task runs against one reference and must produce the same bytes on stdout and the same digest for every file it leaves behind. Comparing the length of programs that do different things is meaningless, so that check gates everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ruby src/run.rb text
&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;  t1_logs     count requests and errors per path in an access log    53 impls  identical
  t2_config   rename one key across a tree of .env files, decoy key left alone 53 impls  identical
  t3_rename   renumber screenshot-N.png to zero-padded shot-NNNN.png 53 impls  identical
  t4_csv      filter a CSV to rows with stock, compute totals, write a CSV 53 impls  identical

EQUIVALENT: every implementation matches one reference (stdout + output tree)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The baseline: it writes Python
&lt;/h2&gt;

&lt;p&gt;Ten runs, same four specs, no language named anywhere. Five in the clean room, five with my real setup after the rule came out.&lt;/p&gt;

&lt;p&gt;The prompt is not blind, and I should say so. It asks for one sentence on why the agent chose what it chose, which tells it the choice is being watched. A blind version is the run I would do next.&lt;/p&gt;

&lt;p&gt;All ten wrote Python.&lt;/p&gt;

&lt;p&gt;Not one reached for Ruby, or bash, or JavaScript. The reasons they gave were about the standard library: preinstalled everywhere, handles line-terminator preservation and fixed-decimal formatting without dependencies. So the rule is not choosing between Ruby and some abstract field. It is displacing Python, every time.&lt;/p&gt;

&lt;p&gt;Five of those runs had my setup loaded and still chose Python, which is only worth reporting if the setup was genuinely in the room. It was. Asked directly, a session in that condition quotes my &lt;code&gt;CLAUDE.md&lt;/code&gt; back at me, and the same setup moves the byte counts by up to 59% below. It was read. It simply has nothing left to say about language once the rule is out of it.&lt;/p&gt;

&lt;p&gt;That makes the cost question concrete, and the answer is not close:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bytes, four scripts, neutral prompt&lt;/th&gt;
&lt;th&gt;Ruby&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;bash&lt;/th&gt;
&lt;th&gt;JS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;source&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1660&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3644&lt;/td&gt;
&lt;td&gt;2333&lt;/td&gt;
&lt;td&gt;3231&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vs Ruby&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;+120%&lt;/td&gt;
&lt;td&gt;+41%&lt;/td&gt;
&lt;td&gt;+95%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ruby is the shortest of the four, and in the clean room the agent's Python runs to more than twice the source for the same verified behaviour.&lt;/p&gt;

&lt;p&gt;That number is condition-dependent, and my own environment is much less flattering. Run it again with my real setup loaded and Python's lead over Ruby drops from 120% to 18%. What the rule buys you depends on what else is already shaping how your agent writes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;the rule's saving&lt;/th&gt;
&lt;th&gt;unprompted default&lt;/th&gt;
&lt;th&gt;told Ruby&lt;/th&gt;
&lt;th&gt;cut&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clean room&lt;/td&gt;
&lt;td&gt;3595&lt;/td&gt;
&lt;td&gt;1660&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;my real environment&lt;/td&gt;
&lt;td&gt;1932&lt;/td&gt;
&lt;td&gt;1375&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both rows are five runs against one, measured the same way. The clean-room row isolates the rule. The bottom row is where you actually are, if your agent is configured at all, and it is the number I would plan against.&lt;/p&gt;

&lt;p&gt;The counter drops comments and blank lines but keeps imports, since having to import is a real cost of the language. It also keeps Python docstrings, which is a point against Python that a &lt;code&gt;#&lt;/code&gt; comment would not have cost it. Those are 8% of the clean-room Python total, and removing them leaves it 101% larger than Ruby instead of 120%.&lt;/p&gt;

&lt;p&gt;Nor is the gap all Ruby. Most of that 120% is not the language, it is what the agent wraps around it: the docstring, the &lt;code&gt;main()&lt;/code&gt;, the &lt;code&gt;if __name__&lt;/code&gt; guard, the line-ending bookkeeping. Discourage those habits and Python's lead falls to 18%, near the 36% I get writing both by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the difference looks like
&lt;/h2&gt;

&lt;p&gt;One task, the config migration, as the neutral prompt produced it. Ruby first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ARGV&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="no"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"conf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"*.env"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;renamed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

  &lt;span class="n"&gt;rewritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_with?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"CACHE_TTL="&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;renamed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="s2"&gt;"TTL_SECONDS="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="s2"&gt;"CACHE_TTL="&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;line&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;

  &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;renamed&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="nb"&gt;puts&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;renamed&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole program. Read it once and you know it is right.&lt;/p&gt;

&lt;p&gt;Python's version of the same task runs to 38 lines. Here is the middle of it, the loop that does the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;        &lt;span class="n"&gt;renamed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keepends&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;ending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sep&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;OLD_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;lines&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;NEW_KEY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ending&lt;/span&gt;
                &lt;span class="n"&gt;renamed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;text&lt;/code&gt; is the file's contents; &lt;code&gt;OLD_KEY&lt;/code&gt; and &lt;code&gt;NEW_KEY&lt;/code&gt; are the two key names, defined at the top of the file. That is the same loop as the Ruby, plus manual line-ending bookkeeping, wrapped in a &lt;code&gt;main()&lt;/code&gt; with a docstring, four imports and an &lt;code&gt;if __name__&lt;/code&gt; guard. None of it is bad Python. It is just more of it, and the count agrees:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;neutral prompt&lt;/th&gt;
&lt;th&gt;Ruby&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;bash&lt;/th&gt;
&lt;th&gt;JS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lines&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;108&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;distinct words&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;104&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;longest line&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;65&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;punctuation, share of non-space characters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;37%&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ruby wins the two that track reading effort, and loses longest-line to bash. I picked these four before running them and I report all four whichever way they fall, because there is no accepted way to measure readability and a metric chosen after the fact measures the author.&lt;/p&gt;

&lt;h2&gt;
  
  
  bash only looks cheap when you ask it to be
&lt;/h2&gt;

&lt;p&gt;Told nothing, the agent writes careful bash: &lt;code&gt;shopt -s nullglob&lt;/code&gt;, an explicit sort, a temp file per input, and a check for whether the original ended with a newline so it can put it back. That is 2333 bytes, 41% longer than Ruby.&lt;/p&gt;

&lt;p&gt;So I ran the neutral prompt a second time with one thing changed: &lt;code&gt;--safe-mode&lt;/code&gt; off, so my real setup loads instead of the clean room. Same prompt, same specs, same fixture.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bytes, neutral prompt&lt;/th&gt;
&lt;th&gt;Ruby&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;bash&lt;/th&gt;
&lt;th&gt;JS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;clean room&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1660&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3644&lt;/td&gt;
&lt;td&gt;2333&lt;/td&gt;
&lt;td&gt;3231&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;my real setup loaded&lt;/td&gt;
&lt;td&gt;1375&lt;/td&gt;
&lt;td&gt;1627&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;945&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2163&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;change&lt;/td&gt;
&lt;td&gt;-17%&lt;/td&gt;
&lt;td&gt;-55%&lt;/td&gt;
&lt;td&gt;-59%&lt;/td&gt;
&lt;td&gt;-33%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every language shrinks, and the winner changes. In the clean room Ruby is shortest. Load my setup and bash is shortest by 31%, which is the tidy "just use bash" result, produced entirely by my own environment.&lt;/p&gt;

&lt;p&gt;I cannot pin that on one sentence, and should not try. Turning &lt;code&gt;--safe-mode&lt;/code&gt; off restores my &lt;code&gt;CLAUDE.md&lt;/code&gt;, skills, hooks and plugins at once, and one of those plugins injects a persona built around writing the shortest thing that works. The honest label on that row is "everything I normally run with".&lt;/p&gt;

&lt;p&gt;Both numbers are real. They answer different questions, and only one of them was the question I asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  How few words buy terse code
&lt;/h2&gt;

&lt;p&gt;If one line of configuration moves the numbers that far, what is the cheapest instruction that moves them on purpose? I ran a ladder: same specs, same clean room, one agent per language per rung, with only the instruction changing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;instruction&lt;/th&gt;
&lt;th&gt;words&lt;/th&gt;
&lt;th&gt;Ruby&lt;/th&gt;
&lt;th&gt;Python&lt;/th&gt;
&lt;th&gt;bash&lt;/th&gt;
&lt;th&gt;JS&lt;/th&gt;
&lt;th&gt;all&lt;/th&gt;
&lt;th&gt;vs none&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;(nothing)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1660&lt;/td&gt;
&lt;td&gt;3644&lt;/td&gt;
&lt;td&gt;2333&lt;/td&gt;
&lt;td&gt;3231&lt;/td&gt;
&lt;td&gt;10868&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Be terse."&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1275&lt;/td&gt;
&lt;td&gt;1830&lt;/td&gt;
&lt;td&gt;906&lt;/td&gt;
&lt;td&gt;2347&lt;/td&gt;
&lt;td&gt;6358&lt;/td&gt;
&lt;td&gt;-41%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Golf it."&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;735&lt;/td&gt;
&lt;td&gt;1033&lt;/td&gt;
&lt;td&gt;743&lt;/td&gt;
&lt;td&gt;1369&lt;/td&gt;
&lt;td&gt;3880&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-64%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Shortest code possible."&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;799&lt;/td&gt;
&lt;td&gt;1151&lt;/td&gt;
&lt;td&gt;659&lt;/td&gt;
&lt;td&gt;1448&lt;/td&gt;
&lt;td&gt;4057&lt;/td&gt;
&lt;td&gt;-63%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Write the shortest X code possible. Minimise source length aggressively."&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;697&lt;/td&gt;
&lt;td&gt;1062&lt;/td&gt;
&lt;td&gt;639&lt;/td&gt;
&lt;td&gt;1322&lt;/td&gt;
&lt;td&gt;3720&lt;/td&gt;
&lt;td&gt;-66%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same plus six explicit rules&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;685&lt;/td&gt;
&lt;td&gt;863&lt;/td&gt;
&lt;td&gt;532&lt;/td&gt;
&lt;td&gt;1243&lt;/td&gt;
&lt;td&gt;3323&lt;/td&gt;
&lt;td&gt;-69%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two words get you 64 of the 69 points available. The 34 words after them buy the last five, which at one run per cell is inside the noise: adjacent rungs swing by up to 11% per language, and the 2-word rung already beats the 3-word one.&lt;/p&gt;

&lt;p&gt;The words themselves matter far more than how many there are. "Be terse." and "Golf it." are both two words, and "Golf it." produces 39% less code overall, and less in every one of the four languages. "Golf" is a term of art, and it drags in everything the phrase "code golf" implies. "Terse" just asks politely.&lt;/p&gt;

&lt;p&gt;If you want an agent to write compact code, "Golf it." is the whole prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What terse code costs
&lt;/h2&gt;

&lt;p&gt;Here is that same config migration again, at the bottom of the ladder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;ARGV&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="s2"&gt;"/conf/*.env"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;gsub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^CACHE_TTL=/&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="s2"&gt;"TTL_SECONDS="&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&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="o"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, 144 bytes against 409, and it passes the same byte-for-byte check.&lt;/p&gt;

&lt;p&gt;Three things in there need decoding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;it&lt;/code&gt; is Ruby's implicit block parameter, so each &lt;code&gt;it&lt;/code&gt; is the current filename. It needs Ruby 3.4 or newer. On anything older this line is an error, not a slow read.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;gsub&lt;/code&gt; block both counts a match and returns the replacement, so &lt;code&gt;n&lt;/code&gt; grows as a side effect of building the new text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[File.basename(it),n]*"\t"&lt;/code&gt; is an array joined by a tab, because &lt;code&gt;*&lt;/code&gt; on an array is &lt;code&gt;join&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had to look twice, in my own language.&lt;/p&gt;

&lt;p&gt;The line counts collapse and the lines themselves get worse. Ruby's longest line goes from 80 characters to 151, JavaScript's from 73 to 199, and punctuation climbs from 24% of the non-space characters to 37%. You are not buying compact code. You are buying dense code, and paying in the thing the rule was adopted for.&lt;/p&gt;

&lt;p&gt;Then I pointed all 152 implementations at a directory with a space in its name, the six rungs plus the hand-written set as a control. One rung broke, and it is the expensive one: every bash script written under the 36-word instruction failed, all four, while the bash from every shorter rung survived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{e[$7]+=$9&amp;gt;=400;t[$7]++}END{for(p in t)print p"\t"e[p]"\t"t[p]}'&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt;/logs/access.log|LC_ALL&lt;span class="o"&gt;=&lt;/span&gt;C &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt;,2nr &lt;span class="nt"&gt;-k1&lt;/span&gt;,1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;$1&lt;/code&gt; is unquoted. Three of the four exit 0 anyway, because a failing &lt;code&gt;awk&lt;/code&gt; piped into &lt;code&gt;sort&lt;/code&gt; reports the exit status of &lt;code&gt;sort&lt;/code&gt;. Two of those three print nothing at all and the third prints &lt;code&gt;-1&lt;/code&gt;. The error does reach stderr, so a person watching the terminal sees it. Anything checking only the exit status does not.&lt;/p&gt;

&lt;p&gt;So the last 34 words of instruction bought five percentage points of length and broke every bash script they touched. "Golf it." did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Length is bytes. I did not run a tokenizer over any of these sets, so every percentage here is a byte percentage, and how closely it tracks a token bill is an assumption I have not tested.&lt;/li&gt;
&lt;li&gt;One agent run per language per rung. The direction is consistent across four languages and six rungs. The exact percentages are not.&lt;/li&gt;
&lt;li&gt;Four tasks, taken from the article's four categories so I could not pick favourable ground, but still four.&lt;/li&gt;
&lt;li&gt;Ten runs is enough to say the default is Python here. It is not enough to put a number on how often.&lt;/li&gt;
&lt;li&gt;The clean room removes configuration, not the model's own priors. "No config" is the honest floor I can reach, not a neutral universe, and a different model may default somewhere else entirely.&lt;/li&gt;
&lt;li&gt;The spaced-path failure is one awkward path on one machine. It shows the failure mode, not a rate that transfers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would actually tell you
&lt;/h2&gt;

&lt;p&gt;Keep the rule. It wins on cost alone, which is not the argument its author made for it: against Python, the language your agent picks when you say nothing, it cuts source by about a third in a configured setup and by half in a bare one.&lt;/p&gt;

&lt;p&gt;Do not reach for a terseness instruction instead. Those cut more than the rule does, but they buy density, not brevity: less code that nobody can read, in a language you did not choose. Push far enough down that ladder and it broke four bash scripts on a path with a space, though "Golf it." itself did not.&lt;/p&gt;

&lt;p&gt;Override it in three places. When the library only exists elsewhere, which is Ghinda's own exception. When the script has to run where Ruby is not installed, which the baseline runs raised themselves by picking Python for being preinstalled everywhere. And when the job is one honest shell command, which the rule already exempts.&lt;/p&gt;

&lt;p&gt;Ghinda's own reason is still the better one, and it is the one no table here can settle. Reviewability is a fact about the reader. Those same numbers would hold for someone who cannot read Ruby at all, and for them every row above stops meaning anything.&lt;/p&gt;

&lt;p&gt;One thing carries past Ruby. Every number here moved, and two of them reversed, depending only on whether my own setup was loaded. If you benchmark an agent, take your config off first, or you are measuring your config.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>ai</category>
      <category>python</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I wrote down 61 rules, then failed my own repos</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:54:40 +0000</pubDate>
      <link>https://dev.to/svyatov/i-wrote-down-61-rules-then-failed-my-own-repos-5351</link>
      <guid>https://dev.to/svyatov/i-wrote-down-61-rules-then-failed-my-own-repos-5351</guid>
      <description>&lt;p&gt;Nobody starts a library because they wanted to write a SECURITY.md.&lt;/p&gt;

&lt;p&gt;You start it because there is a thing you want to exist. Then you publish it, and a second job arrives that nobody signed up for: issue templates, a code of conduct, pinned action SHAs, a changelog that survives contact with a release, a publish job that does not leave a long-lived token lying around, and a README that still tells the truth eighteen months later.&lt;/p&gt;

&lt;p&gt;That is the boring half. It is mostly mechanical, and it decides whether a stranger with a good patch can figure out how to send it to you.&lt;/p&gt;

&lt;p&gt;So I wrote it down. &lt;a href="https://oss-kit.svyatov.com" rel="noopener noreferrer"&gt;oss-kit&lt;/a&gt; is 61 rules about that half of the job and nine agent skills that fix them. Then I pointed it at two repositories of my own. The first failed 27 rules. The second I had already put through the fixing loop, so it is the one that shows what is left at the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the boring half contains
&lt;/h2&gt;

&lt;p&gt;Before you can hand the work off, somebody has to say what the work is. Seven areas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Documentation&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;README order, claims that match the source, prose that is not marketing copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Community&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;License, contributing guide, code of conduct, security policy, issue forms, governance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuous integration&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;What runs on push and on every change request, on which runtimes, and whether tests exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security posture&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Pinned action SHAs, workflow permissions, branch and tag rules, lockfiles, signed tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release and publishing&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Trusted publishing, provenance, approval gates, bills of materials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changelog and versioning&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Keep a Changelog structure, the right bump, release notes that match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent skills&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Layout, frontmatter, licensing and portability, if your repo ships skills&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two areas are gated by a precondition rather than by a setting. Publishing needs a repository that ships a built artifact, and it then splits again: on the registry-push track every rule applies, and on the tag-published track, where the registry reads your forge and no publishing credential exists at all, four of the seven have nothing to attach to. Go modules and Packagist live on that second track. The agent-skills area needs a &lt;code&gt;SKILL.md&lt;/code&gt; somewhere in the repo.&lt;/p&gt;

&lt;p&gt;That scoping is most of what an audit spends its time on. On the untouched repository below, 18 of the 61 rules came back not applicable, each with the reason: no skills, one principal holding every merge path, a release carrying no built asset, a package manager that gives you no way to decline dependency install code. That is how you tell a rule that does not reach you from one that quietly got dropped.&lt;/p&gt;

&lt;p&gt;On forges: 57 of the 61 rules score GitHub and GitLab alike. Three are GitHub-only, one is GitLab-only, and every rule states which.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an agent needed a standard first
&lt;/h2&gt;

&lt;p&gt;A table like that is easy to write and useless on its own, which is the part I got wrong the first time. Ask an agent to improve your README and you get something plausible. It will be well-written. You will have no way to tell whether it is better, because "better" was never defined, and neither of you can point at anything.&lt;/p&gt;

&lt;p&gt;So every opinion in oss-kit lives in one file as a numbered rule, and every rule carries a line naming the evidence you can go look at. Not "the README should be welcoming" but: the first paragraph after the title is a single sentence naming what the project is, and it appears before any table of contents or badge row. That is a claim you can go and check for yourself, and tell me I am wrong about. "Welcoming" is not.&lt;/p&gt;

&lt;p&gt;The same discipline is what makes the kit reach past the language I happen to write in. Eleven ecosystems get a real answer rather than a footnote: npm, PyPI, RubyGems, crates.io, Go modules, Packagist, NuGet, Maven Central, Hex, pub.dev, and container images. Seven of the nine skills carry one file per ecosystem, 77 files in total, each ending with the date its source was last read. A script fails CI on a missing file, a file no roster lists, a declared heading left empty, or a date that does not parse.&lt;/p&gt;

&lt;p&gt;Whether a library commits a lockfile is not one question with one answer, so the files answer it per ecosystem, each citing the documentation that settles it.&lt;/p&gt;

&lt;p&gt;This is also the honest answer to "why not just use OpenSSF Scorecard." Scorecard is good and I use its data, but it is security-scoped: branch protection, pinned dependencies, code review, fuzzing. It has no opinion about your README, your changelog, or whether a contributor can find out how to run your tests.&lt;/p&gt;

&lt;p&gt;CNCF's &lt;a href="https://github.com/cncf/clomonitor" rel="noopener noreferrer"&gt;CLOMonitor&lt;/a&gt; does have those opinions, and it is the closest thing to this that already exists. It scores documentation, community files, licensing and security posture against a public checklist. It is also a hosted dashboard aimed at foundation-hosted projects, it does not read GitLab, and it stops at the report. Nothing in it hands a finding to the thing that fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;clsx-ruby&lt;/code&gt; is a gem of mine: green CI, published to RubyGems, six releases, and I would have told you it was in decent shape. I audited it a week ago, fixed none of what it found, and let exactly one commit land since, a development-dependency bump. So the report below is that same repository, untouched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;clsx-ruby
npx skills add svyatov/oss-kit &lt;span class="nt"&gt;--skill&lt;/span&gt; &lt;span class="s1"&gt;'*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, to the agent: &lt;em&gt;Audit this repository against the oss-kit standard.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Sixty-one verdict lines came back: 16 pass, 27 fail, 0 unknown, 18 not applicable. Every fail carries the evidence it read and the skill that owns the fix. Three of them, verbatim, with the &lt;code&gt;Check:&lt;/code&gt; text each one quotes cut for length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- R-COM-04 fail no `SECURITY.md` at the repository root, in `.github/`, or in `docs/`
  Fixed by: oss-community
- R-SEC-11 fail rubygems: `dependency-graph/sbom` lists 11 packages, every one a direct `Gemfile`
  entry, plus the 3 actions; the on-disk `Gemfile.lock` resolves 29 gems, so the 18 transitive
  dependencies are watched by nothing, because no lockfile is committed for the graph to read.
  `vulnerability-alerts` answers 204, so the alerting itself is enabled
  Fixed by: oss-harden
- R-PUB-03 fail rubygems: `https://rubygems.org/api/v1/attestations/clsx-ruby-1.2.0.json` answers
  200 with an empty array, so the registry serves no attestation for the exact published version
  Fixed by: oss-publish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of the twenty-seven, in the order that they bother me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody can report a vulnerability in this gem privately.&lt;/strong&gt; There is no SECURITY.md, so a person who finds something either opens a public issue describing it or gives up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eighteen of the twenty-nine gems it resolves are watched by nothing.&lt;/strong&gt; The dependency graph reads the &lt;code&gt;Gemfile&lt;/code&gt;, the &lt;code&gt;Gemfile.lock&lt;/code&gt; is in &lt;code&gt;.gitignore&lt;/code&gt;, so everything transitive is invisible to the one tool that would tell me about an advisory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of its actions are pinned to a tag rather than a commit.&lt;/strong&gt; &lt;code&gt;actions/checkout@v4&lt;/code&gt;, &lt;code&gt;ruby/setup-ruby@v1&lt;/code&gt; and &lt;code&gt;codecov/codecov-action@v5&lt;/code&gt;, each a name whoever publishes it can repoint whenever they like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The README opens with "The fastest".&lt;/strong&gt; There is a benchmark table that backs that up, 55 lines down, past the point where a reader decides. A superlative with its evidence out of reach is the exact thing I wrote a rule against, in a README I wrote myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The published gem has no attestation.&lt;/strong&gt; Nothing ties &lt;code&gt;clsx-ruby-1.2.0.gem&lt;/code&gt; back to the commit it was built from. The audit did not conclude that from a 404, which is the difference between a fail and an honest &lt;code&gt;unknown&lt;/code&gt;: a guessed URL answering 404 proves nothing about the gem.&lt;/p&gt;

&lt;p&gt;The report is a file now, &lt;code&gt;oss-audit-report.md&lt;/code&gt;, written at the root of the repo and untracked. It carries a verdict line for all 61 rules, passes included, and it ends by grouping the 27 fails into seven pull requests with the dependencies between them spelled out. Community files first, because SECURITY.md is what the security rules build on. Commit the lockfile before the rule that reads the dependency graph, because one closes the other. Publishing last, because two of its rules can only be verified against a newly published artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then you fix them
&lt;/h2&gt;

&lt;p&gt;None of that is work that gets done by reading a report, which is what the other repository is for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sec_id&lt;/code&gt; is a gem I have maintained for years: 17 published releases, every community file present, contributing guide, code of conduct, security policy, issue templates. When I first ran this audit on it, against a standard that held 46 rules, it scored 19 pass, 18 fail, and one honest &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then I ran the fixing loop on that report. Eight pull requests, which I grouped by owning skill myself, because the report file did not exist yet: pin the actions and commit the lockfiles, publish through trusted publishing, state the governance, give the README a differentiator, resolve the changelog version links.&lt;/p&gt;

&lt;p&gt;Same prompt, same repo, seven days and 61 rules later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../sec_id
&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;Audited 51 applicable rules: 44 pass, 7 fail, 0 unknown, 10 not applicable.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen fails to seven, while the standard grew by fifteen rules underneath it. The &lt;code&gt;unknown&lt;/code&gt; closed too, and on real evidence: the sigstore bundle's digest matches the published gem's checksum, and the certificate names the workflow and the tag it was built from.&lt;/p&gt;

&lt;p&gt;The standard moving did not only cost me. &lt;code&gt;R-COM-06&lt;/code&gt; wanted a CODEOWNERS file and &lt;code&gt;sec_id&lt;/code&gt; failed it on the first run. It now scores not applicable, because the rule gained an exclusion for a repository where one principal holds every merge path. No file was ever written. Some of the drop from eighteen to seven is the rule moving rather than the repo.&lt;/p&gt;

&lt;p&gt;The seven survivors are what a fixing pass could not close.&lt;/p&gt;

&lt;p&gt;Two are rules that did not exist when I fixed it, and the next section is about them. Two are failures the fixing caused: one pull request added a &lt;code&gt;SKILL.md&lt;/code&gt;, which pulled the whole agent-skills area into scope, and two of its seven rules fail. One is a rule whose text changed under a config I never touched, and one is a check I think is simply wrong. Those two get the sections after that.&lt;/p&gt;

&lt;p&gt;The seventh is the one I would call plain old debt. &lt;code&gt;R-CHG-05&lt;/code&gt; wants a removal to have been deprecated in some earlier release, and two of &lt;code&gt;sec_id&lt;/code&gt;'s major versions removed things that never were. Both are published, so nothing can go back and deprecate them now. The contributing guide already states the gap outright, which is about as honest as that gets.&lt;/p&gt;

&lt;p&gt;Fixing a repository is how you find the rules that were never reaching it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard moves under you
&lt;/h2&gt;

&lt;p&gt;Fifteen rules in seven days, and both repositories fail the same two new ones.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;R-SEC-14&lt;/code&gt; asks your dependency updater to wait before proposing a version published minutes ago, through Dependabot's &lt;code&gt;cooldown&lt;/code&gt; or Renovate's &lt;code&gt;minimumReleaseAge&lt;/code&gt;. &lt;code&gt;R-SEC-13&lt;/code&gt; asks for a ruleset over &lt;code&gt;refs/tags/*&lt;/code&gt; that blocks tag updates and deletions and names who may create one. Both repos guard their default branch, neither guards a tag, so a released version number can still be repointed at a different commit. That is the one change a version number exists to make visible.&lt;/p&gt;

&lt;p&gt;The cooldown rule is the one I would have defended hardest, so here is what writing this post did to it.&lt;/p&gt;

&lt;p&gt;The argument goes: what ends a registry compromise is other people looking, and that takes hours. An updater with no cooldown opens its pull request inside that window, so the project that merges its bumps promptly is the one that installs the bad version first. Diligence becomes the exposure.&lt;/p&gt;

&lt;p&gt;Both my repos fail on a Dependabot config with no &lt;code&gt;cooldown&lt;/code&gt; block, which is a fair reading of the check. But GitHub &lt;a href="https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference" rel="noopener noreferrer"&gt;applies a default cooldown of three days&lt;/a&gt; to version updates when you have configured none, and three days outlasts the window the rule was written about. The exposure I just described is not present in either repository.&lt;/p&gt;

&lt;p&gt;Renovate is where it survives. Its &lt;code&gt;minimumReleaseAge&lt;/code&gt; &lt;a href="https://docs.renovatebot.com/renovate-schema.json" rel="noopener noreferrer"&gt;defaults to &lt;code&gt;null&lt;/code&gt;&lt;/a&gt;, so a Renovate repo with nothing set waits zero days. Same rule, same fail, two situations that are not remotely alike, and the rule text cannot tell you which one you are in. I have &lt;a href="https://github.com/svyatov/oss-kit/issues/69" rel="noopener noreferrer"&gt;filed that against my own standard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What is left of the rule is still worth having, and it is a narrower thing than I wrote down: a written setting does not move when the platform changes its default, and it is a decision somebody can audit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/dependabot.yml&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;updates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bundler"&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly"&lt;/span&gt;
    &lt;span class="na"&gt;cooldown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;default-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7&lt;/span&gt;
      &lt;span class="na"&gt;semver-patch-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole fix, and both of my repos still do not have it.&lt;/p&gt;

&lt;p&gt;A third new rule reaches neither repository and is the one I would read first. &lt;code&gt;R-SEC-15&lt;/code&gt; asks CI to install dependencies without running the code they ship. That is the step the September 2025 npm worm used to spread. &lt;a href="https://unit42.paloaltonetworks.com/npm-supply-chain-attack/" rel="noopener noreferrer"&gt;Unit 42's writeup&lt;/a&gt; records that the malicious versions "contain a worm that executes a post-installation script." Installing was enough.&lt;/p&gt;

&lt;p&gt;It falls outside eight of the eleven ecosystems rather than failing them, RubyGems among them, which is why both gems score it not applicable. Cargo, RubyGems and Hex give you no documented way to decline. Maven, NuGet, Go modules, container images and pub.dev run nothing during resolution at all.&lt;/p&gt;

&lt;p&gt;There is a cost to writing rules this fast, and one of &lt;code&gt;sec_id&lt;/code&gt;'s seven fails is it. &lt;code&gt;R-CI-01&lt;/code&gt; scores a trigger block that has not changed since February. What changed is the rule: a &lt;code&gt;pull_request&lt;/code&gt; trigger carrying a &lt;code&gt;branches:&lt;/code&gt; filter now fails where it used to pass. The project is below 1.0.0, so an incompatible change ships in a minor release, and a repo that passed yesterday can fail today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not do
&lt;/h2&gt;

&lt;p&gt;The rules are the part you can argue with. The scoring is the part I would argue with.&lt;/p&gt;

&lt;p&gt;Five rules ship with a real checker, a dependency-free script that either exits 0 or does not. The other 56 are scored by a model reading a check line. That ratio has moved the wrong way: the standard grew by fifteen rules and the checker still covers the same five.&lt;/p&gt;

&lt;p&gt;The kit did gain five scripts in that time, and not one of them scores a rule. They collect the facts a check line refers to. The one script that returns a verdict is the one it already shipped with.&lt;/p&gt;

&lt;p&gt;A collector can also be wrong. On &lt;code&gt;sec_id&lt;/code&gt;, the bundled one reported two CI jobs where the repository has seven, with a &lt;code&gt;steps: 0&lt;/code&gt; beside each as the tell. The rule it fed passes either way. An audit that filtered on that output would have scored a five-job blind spot without noticing.&lt;/p&gt;

&lt;p&gt;The model half is worse. One check asks that every code block name the file or tool it goes to. &lt;code&gt;sec_id&lt;/code&gt;'s README fails that on roughly 35 REPL-style blocks under a literal reading, and passes under the intent reading.&lt;/p&gt;

&lt;p&gt;A check that would fail essentially every library README ever written is telling you about the check, not the README. Another rule passed on judgement while the bundled validator exits 1, because counting it would have reported one gap twice.&lt;/p&gt;

&lt;p&gt;Then there is the one I did not see coming. The kit added a closing step that re-audits after the fixes and diffs the two reports, so a fix that broke something else surfaces as a verdict that went from pass to fail.&lt;/p&gt;

&lt;p&gt;I could not run it. The first audit predated the report file, so its verdicts were chat output and nothing else. A verdict nobody wrote down cannot be compared, and my own repo is the worked example.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nine skills
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scores the repository and routes each gap to a skill.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-community&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes every community file, from the license to issue forms.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-readme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Orders the README and checks its claims against the source.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-ci&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes the test, lint, and build jobs for either forge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-harden&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pins action SHAs, trims permissions, and guards branches and tags.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-publish&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Publishes to eleven ecosystems, gates the run, and signs it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-changelog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps CHANGELOG.md, picks the bump, writes release notes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-writing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fixes the sentences in commits, reviews, docs, and issues.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oss-skill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fixes the structure and portability of the skills you ship.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What these skills actually do is refuse things, and the refusals are why I let them near my repositories at all. (These are design commitments written into the skills, not behavior this post tested end to end.)&lt;/p&gt;

&lt;p&gt;The missing SECURITY.md goes to &lt;code&gt;oss-community&lt;/code&gt;, which will not write &lt;code&gt;security@example.com&lt;/code&gt;, or any plausible-looking address nobody confirmed, and will not promise a response window you never agreed to. If the repo does not tell it where security reports should land, it stops and asks me. It also prefers GitHub issue forms over Markdown templates, because a template made of Markdown headings cannot require a field, so a contributor can delete every heading and submit an empty issue.&lt;/p&gt;

&lt;p&gt;The README's out-of-reach superlative goes to &lt;code&gt;oss-readme&lt;/code&gt;, which matches every claim in a README against the manifest, the source and the CI config. It also will not pick your README's facts for you: it sweeps the repo, sorts candidates into the questions a reader has, lists what it rejected and why, then stops and makes you choose three to five. Left alone, a writer picks whatever the source tree makes easy, which is how a facts list ends up describing how a project is built instead of what it does.&lt;/p&gt;

&lt;p&gt;The three actions pinned to mutable tags go to &lt;code&gt;oss-harden&lt;/code&gt;, which pins them to full commit SHAs. What it will not do is pin a dependency whose origin it could not establish in the first place. Pinning gives you the same unverified code at a known revision, and the next person to read that line will assume somebody vouched for it. It reports that as an observation with no rule ID attached, because no rule reaches it.&lt;/p&gt;

&lt;p&gt;The unattested gem goes to &lt;code&gt;oss-publish&lt;/code&gt;, which moves publishing into a workflow that authenticates without a stored token. It will not fake a missing approval gate with some third-party action it found; it reports the rule as unmet and says so.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;oss-writing&lt;/code&gt; is the one I use before writing anything at all. It gives a commit or a pull request a body only when one of six named triggers applies, and the default is no body. It also refuses to add &lt;code&gt;Co-Authored-By: Claude&lt;/code&gt; trailers, on the grounds that a trailer records who is accountable for a change and a tool cannot be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules you will argue with
&lt;/h2&gt;

&lt;p&gt;All of which assumes you accept the rules doing the routing. I am not going to tell you these are 61 uncontroversial best practices, because several of them are not.&lt;/p&gt;

&lt;p&gt;The README rule puts your badge row &lt;em&gt;below&lt;/em&gt; the opening sentence. PostCSS and Nano ID put badges above, and they are more successful projects than anything I have written. The skill concedes the divergence in writing and states the condition under which it holds.&lt;/p&gt;

&lt;p&gt;The prose rule bans every emoji character in project documentation. I argued against my own rule on this one, wrote "banning emojis in open source might be way too strict, emojis are fun," and then never changed it.&lt;/p&gt;

&lt;p&gt;Two of the agent-skill rules are deliberately stricter than the Agent Skills specification. The spec permits Python, Bash and JavaScript in a bundled script and leaves the choice to the implementation; oss-kit allows only &lt;code&gt;sh&lt;/code&gt; or dependency-free Node. The spec marks the &lt;code&gt;license:&lt;/code&gt; field optional; oss-kit requires it, because an installer may extract one skill directory without your repository's license file. That second one is a rule &lt;code&gt;sec_id&lt;/code&gt; now fails, which is how I know it bites.&lt;/p&gt;

&lt;p&gt;And the versioning rule overrides SemVer during &lt;code&gt;0.y.z&lt;/code&gt;, using MINOR for incompatible changes rather than forcing a premature 1.0.0.&lt;/p&gt;

&lt;p&gt;You can read every rule, with its reasoning and its check, &lt;a href="https://oss-kit.svyatov.com/standard/" rel="noopener noreferrer"&gt;without installing anything&lt;/a&gt;. That is deliberate. Disagreeing with rule R-DOC-05 by name is a better outcome than nodding along to a prompt you never read, and there is a rule-proposal issue form for doing something about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Install the whole kit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add svyatov/oss-kit &lt;span class="nt"&gt;--skill&lt;/span&gt; &lt;span class="s1"&gt;'*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or one skill at a time, if you only want the README one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add svyatov/oss-kit &lt;span class="nt"&gt;--skill&lt;/span&gt; oss-readme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Claude Code or GitHub Copilot CLI, type these two at the prompt instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin marketplace add svyatov/oss-kit
/plugin install oss-kit@oss-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine hosts are recorded, each with the paths it reads and the date that was checked. Two of them have no command-line install at all, so placing the directory is the install. Then ask your agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Audit this repository against the oss-kit standard.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authenticate &lt;code&gt;gh&lt;/code&gt; before you do, because a checkout cannot answer what the audit needs to score. Rulesets, environments, the dependency graph and release bodies are all forge reads. Without them those rules come back &lt;code&gt;unknown&lt;/code&gt; rather than wrong, which is the correct answer and a much thinner report.&lt;/p&gt;

&lt;p&gt;Read the report, then run whichever skill each line named. The audit changes nothing it scores; it writes one file. The skills do change things, and they are not silent about it: they stop and ask when a file needs a fact your repository does not contain, they propose forge settings rather than changing them under you, and they finish by listing every file written or proposed and every fact that came from asking rather than reading. Skills install independently, so you can take one and ignore the rest.&lt;/p&gt;

&lt;p&gt;If the repo is brand new there is nothing to score yet, so skip the audit and go in order: &lt;code&gt;oss-community&lt;/code&gt; for the license and community files, &lt;code&gt;oss-readme&lt;/code&gt;, &lt;code&gt;oss-ci&lt;/code&gt;, &lt;code&gt;oss-harden&lt;/code&gt;, &lt;code&gt;oss-changelog&lt;/code&gt;, then &lt;code&gt;oss-publish&lt;/code&gt; if you ship a package.&lt;/p&gt;

&lt;p&gt;Run it on something you already maintain. Not a toy repo, the one you would be embarrassed to have someone read. You will get a list. Some of it you will disagree with. The rest is the boring half, finally written down, in an order somebody thought about.&lt;/p&gt;

&lt;p&gt;Then hand it to the agent, because you did not start this project to write a SECURITY.md.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>devops</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Kubernetes earned its security badge in 2017 and never came back</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Fri, 31 Jul 2026 17:23:07 +0000</pubDate>
      <link>https://dev.to/svyatov/kubernetes-earned-its-security-badge-in-2017-and-never-came-back-58eh</link>
      <guid>https://dev.to/svyatov/kubernetes-earned-its-security-badge-in-2017-and-never-came-back-58eh</guid>
      <description>&lt;p&gt;Kubernetes has an &lt;a href="https://www.bestpractices.dev/projects/569" rel="noopener noreferrer"&gt;OpenSSF Best Practices badge&lt;/a&gt;. It earned that badge on 16 August 2017 at 14:52:28 UTC, and nobody has touched the entry since. The save that earned it is still the last anyone made: the two timestamps sit three milliseconds apart, because one save wrote both.&lt;/p&gt;

&lt;p&gt;The badge on that page today looks exactly like a badge earned this morning. It carries no date.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.bestpractices.dev" rel="noopener noreferrer"&gt;bestpractices.dev&lt;/a&gt; has been running for a decade, the CNCF requires it before a project can graduate, and it had never crossed my desk. So I went and read it properly: the criteria, the &lt;a href="https://github.com/ossf/best-practices-badge" rel="noopener noreferrer"&gt;Rails app behind it&lt;/a&gt;, and the daily statistics it has been publishing at a public URL the whole time. This is what I found, including the part the program's own maintainer raised in 2024 and nobody has fixed.&lt;/p&gt;

&lt;p&gt;Every number here was fetched on 31 July 2026, by crawling the site's own JSON API and its published daily statistics. They move daily, so check them rather than trusting mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the thing is
&lt;/h2&gt;

&lt;p&gt;It is a questionnaire. You answer a list of questions about how your project is run, and if you answer enough of them the site gives you a badge image for your README.&lt;/p&gt;

&lt;p&gt;It is free, it takes no review board, and it works for any project anywhere. The Linux kernel's entry points at git.kernel.org. GitLab's own repository holds one. Working anywhere is the badge's real advantage over the automated scanners.&lt;/p&gt;

&lt;p&gt;There are &lt;a href="https://openssf.org/blog/2026/02/25/getting-an-openssf-baseline-badge-with-the-best-practices-badge-system/" rel="noopener noreferrer"&gt;two ladders&lt;/a&gt; now, which is the first confusing thing a newcomer meets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  METAL (2016)                    BASELINE (2026)

  passing   67 criteria           baseline-1   24 controls  \
     |                            baseline-2   19 more       &amp;gt; OpenSSF Baseline,
  silver    55 more               baseline-3   21 more      /  MUST-only controls
     |
  gold      23 more

  3,080 projects at passing       223 at baseline-1
    262 at silver or better       103 at baseline-2
     86 at gold                    44 at baseline-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same form, same login, two unrelated series of questions. The metal ladder is cumulative: a gold badge is 145 criteria deep.&lt;/p&gt;

&lt;p&gt;Anyone with an account can create an entry for any project, and nobody checks that you own it. The first person to file owns it, though GitHub committers on the repository you name can edit it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it came from
&lt;/h2&gt;

&lt;p&gt;Heartbleed, April 2014. Two weeks after the bug went public the Linux Foundation announced the Core Infrastructure Initiative, and the badge was one of the things that initiative produced.&lt;/p&gt;

&lt;p&gt;The repository's first commit is dated 22 July 2015. Dan Kohn, two files, 24 lines: an MIT license and a README containing nothing but the project's name. Two weeks later David A. Wheeler pushed the first draft of the criteria, and the document says out loud what the thing was always going to be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The goal is to eventually create a "badging" program where OSS projects that meet the criteria can voluntarily self-certify that they meet the criteria and then be able to show a badge.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Self-certify. That was the plan on day one, and it is still the plan.&lt;/p&gt;

&lt;p&gt;Greg Kroah-Hartman opened the &lt;a href="https://github.com/ossf/best-practices-badge/pull/1" rel="noopener noreferrer"&gt;first pull request&lt;/a&gt; on 10 August 2015 to delete the code-coverage requirement, arguing coverage "is not a good metric for any sort of assurance other than someone wrote a bunch of tests." It was closed without merging. Eleven days later somebody opened the exact opposite pull request, asking to add coverage back.&lt;/p&gt;

&lt;p&gt;The badge went generally available in May 2016. The announcement carries a line that has aged well:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prior to Heartbleed, OpenSSL failed to meet more than one-third of the CII Best Practices Badge criteria. Today it meets 100 percent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then CII became the OpenSSF, and in August 2023 the site moved from bestpractices.coreinfrastructure.org to bestpractices.dev, which is why old badge links point at a domain that no longer names anything.&lt;/p&gt;

&lt;p&gt;OpenSSL, ten years on, still holds passing and nothing above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 67 questions actually ask
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.bestpractices.dev/en/criteria" rel="noopener noreferrer"&gt;range&lt;/a&gt; is wider than "do you have a license." The 67 sort into six groups, and the split says something before you read a single one: Basics 13, Change Control 9, Reporting 8, Quality 13, Security 16, Analysis 8. Most of the form is asking how you run the project.&lt;/p&gt;

&lt;p&gt;Four of them, verbatim.&lt;/p&gt;

&lt;p&gt;The easy one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The project sites (website, repository, and download URLs) MUST support HTTPS using TLS.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The one you cannot fake with a config change:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The project MUST have at least one primary developer who knows how to design secure software.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The one that is true or false about the world, not about your repository:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There MUST be no unpatched vulnerabilities of medium or higher severity that have been publicly known for more than 60 days.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the one that quietly locks a whole class of project out of gold:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The project MUST have a "bus factor" of 2 or more.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last one is a gold MUST and a silver SHOULD. If you maintain your project alone, silver is reachable by writing a justification and gold is not reachable at all, no matter how good the code is. Gold also wants at least two unassociated significant contributors and half of all changes reviewed by someone other than their author. Gold is measuring your staffing, not your software.&lt;/p&gt;

&lt;p&gt;Answering "Met" is sometimes not enough. Across all 145 criteria, 52 require a written justification and 34 require a URL inside it, and an answer that misses either is dropped from your percentage. That burden is almost entirely a silver and gold problem, though: at passing level it is one justification and eight URLs.&lt;/p&gt;

&lt;p&gt;Passing is lenient in the other direction too. An unmet SUGGESTED criterion still counts toward your score, so a SUGGESTED item only has to be answered rather than met. And where a justification is required, the bar is &lt;code&gt;MIN_SHOULD_LENGTH = 5&lt;/code&gt;. Five characters.&lt;/p&gt;

&lt;p&gt;There is also an undocumented convention: a justification beginning with &lt;code&gt;//&lt;/code&gt; is treated as a comment rather than an answer, and it appears nowhere in the docs or the interface strings.&lt;/p&gt;

&lt;p&gt;How long does it take? The one clean public figure comes from &lt;a href="https://github.com/keycloak/keycloak/discussions/13276#discussioncomment-3464477" rel="noopener noreferrer"&gt;Marc Wrobel&lt;/a&gt;, who filled one in for his own library and said it took "3 to 6 hours to answer all the questions." &lt;a href="https://ideas-productivity.org/events/hpcbp-093-openssf" rel="noopener noreferrer"&gt;Roscoe Bartlett&lt;/a&gt;, teaching the badge to scientific-computing groups at Sandia, puts reading the criteria alone at more than half a day. The Linux Foundation's 2016 press release claimed "generally, in under an hour," which I can find no evidence for.&lt;/p&gt;

&lt;p&gt;Sandia's FIREWHEEL went from entry creation to passing in six hours and two minutes, one sitting. BizHawk opened its entry in November 2021 and is still in progress.&lt;/p&gt;

&lt;p&gt;You do not have to type everything. The site reads your repository and pre-fills what it can, and you can hand it a &lt;code&gt;.bestpractices.json&lt;/code&gt; file or a &lt;code&gt;security-insights.yml&lt;/code&gt; in the repo instead. Autofill only works against GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does anyone actually check?
&lt;/h2&gt;

&lt;p&gt;The maintainers answer this themselves, in &lt;a href="https://github.com/ossf/best-practices-badge/blob/main/docs/vetting.md" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;, and their answer is more careful than most of the criticism I found:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That means that the badge is not strictly self-certification, instead, the badge is merely based on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I went to find out how much work "merely" is doing. The app runs a set of classes it calls detectives, which read your repository and propose answers. Most of them only fill in blanks. Three of them can overrule a human being who claimed "Met", across five criteria: the HTTPS check, the security-headers check, and the license-location check.&lt;/p&gt;

&lt;p&gt;Three, and only on GitHub. The license check reads a file listing the app can fetch from nowhere else, so on GitLab or a self-hosted repo the referee is two checks over three criteria.&lt;/p&gt;

&lt;p&gt;It also runs the other way, which surprised me. Five answers get forced &lt;em&gt;to&lt;/em&gt; Met over whatever the human said, including the license checks.&lt;/p&gt;

&lt;p&gt;The bigger check is social rather than automated, and the maintainers lean on it deliberately: every answer and every justification is published on the project page. That is how I found the dead URLs later in this post. Anyone can read what a project claimed and go and look. And the detectives only wake up when someone saves the form, or opens it with an explicit &lt;code&gt;?reanalyze&lt;/code&gt;. Merely looking at a completed entry runs nothing.&lt;/p&gt;

&lt;p&gt;So what happens when somebody disputes a badge? There is one good test case: in August 2024 a contributor &lt;a href="https://github.com/ossf/best-practices-badge/issues/2153" rel="noopener noreferrer"&gt;filed an issue&lt;/a&gt; saying Netdata should not hold a badge, because it had begun shipping closed-source dashboard blobs that Debian, Alpine and Fedora had all refused. Wheeler's first response was to check the LICENSE file, see GPL-3.0, and call it settled. Three days later he wrote:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I can point to myself; when this issue was first raised, I went to LICENSE, saw "GPL-3.0", and said "sure, that's OSS".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He escalated it to the project mailing list. Nobody replied. The issue is still open two years later, and Netdata's entry still shows a passing badge, last edited in May 2023.&lt;/p&gt;

&lt;p&gt;That is not fraud. It is a volunteer dispute process with one person in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clock that isn't there
&lt;/h2&gt;

&lt;p&gt;Here is the funnel, from the site's own daily statistics:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;where entries sit today&lt;/th&gt;
&lt;th&gt;entries&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;registered an entry&lt;/td&gt;
&lt;td&gt;11,434&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at 25% or more&lt;/td&gt;
&lt;td&gt;5,785&lt;/td&gt;
&lt;td&gt;50.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at 50% or more&lt;/td&gt;
&lt;td&gt;5,026&lt;/td&gt;
&lt;td&gt;44.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at 75% or more&lt;/td&gt;
&lt;td&gt;4,529&lt;/td&gt;
&lt;td&gt;39.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at 90% or more&lt;/td&gt;
&lt;td&gt;3,863&lt;/td&gt;
&lt;td&gt;33.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at passing&lt;/td&gt;
&lt;td&gt;3,080&lt;/td&gt;
&lt;td&gt;26.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at silver or better&lt;/td&gt;
&lt;td&gt;262&lt;/td&gt;
&lt;td&gt;2.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;at gold&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are positions today, not high-water marks, so entries that slipped back are counted where they now sit. Finishing at 26.9% is healthier than the program itself advertises: the repository's README has said "on average only about 10% of pursuing projects have a passing badge" since June 2017, when it was accurate to a tenth of a point. The line has not been edited since.&lt;/p&gt;

&lt;p&gt;Now the interesting half. Of the 3,082 entries holding a passing badge (a live crawl, two ahead of the previous night's statistics row above), 1,806 have never been edited since the write that earned it. Not "not edited recently."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;gap between earning the badge and the last edit&lt;/th&gt;
&lt;th&gt;entries&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;same write (under 100 ms)&lt;/td&gt;
&lt;td&gt;1,806&lt;/td&gt;
&lt;td&gt;58.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 ms to 1 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 s to 1 min&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;1.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 min to 1 h&lt;/td&gt;
&lt;td&gt;340&lt;/td&gt;
&lt;td&gt;11.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 h to 6 h&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 h to 24 h&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;3.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;more than 24 h&lt;/td&gt;
&lt;td&gt;697&lt;/td&gt;
&lt;td&gt;22.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;not classified (two backfill artifacts)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing at all falls between 100 milliseconds and one second, so the top bucket is a category rather than a cutoff I chose: one save, with the skew inside it running from 0 to 48 milliseconds.&lt;/p&gt;

&lt;p&gt;One caveat on that 58.6%, since it flatters the argument: a third of those entries first passed in 2026 and have barely had the chance to come back. Restrict it to badges earned three or more years ago and it is still 53.6%.&lt;/p&gt;

&lt;p&gt;Meanwhile 57.8% of passing entries have gone untouched for a year or more, and 15.5% for five years or more. There is no expiry. I looked for one: the scheduled jobs are a statistics task and a reminder mailer, neither recomputes criteria, the recurring-jobs config is an empty stub, and no CI cron touches badges.&lt;/p&gt;

&lt;p&gt;While a project sits at 100% the main reminder query skips it, but "never emailed again" would be too strong. Drop below 100% and you re-enter the queue after a 30-day grace, which has happened to 49 entries since they first passed, GitLab and Syncthing among them. A separate warning mailer does reach projects still at 100%: ten were queued the day I looked.&lt;/p&gt;

&lt;p&gt;Kubernetes got its last reminder on 15 June 2017. It earned the badge two months later, and nothing has emailed it since.&lt;/p&gt;

&lt;h3&gt;
  
  
  But are the old answers actually wrong?
&lt;/h3&gt;

&lt;p&gt;"Old" and "wrong" are different words. If a project's practices did not change, its answers did not need editing.&lt;/p&gt;

&lt;p&gt;For the famous stale entries, that defence holds. Lynis has not touched its entry since May 2016 and its repository was pushed to this morning. POCO and cjdns are both still alive, and the specific URLs their justifications point at still resolve.&lt;/p&gt;

&lt;p&gt;But the decay is measurable. I resolved every hostname in every passing entry's repository and homepage URLs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;entry last touched&lt;/th&gt;
&lt;th&gt;entries&lt;/th&gt;
&lt;th&gt;dead hosts&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;under 1 year&lt;/td&gt;
&lt;td&gt;1,301&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 to 3 years&lt;/td&gt;
&lt;td&gt;860&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0.58%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 to 5 years&lt;/td&gt;
&lt;td&gt;444&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1.58%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 years or more&lt;/td&gt;
&lt;td&gt;477&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.68%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is 23 dead hosts across the whole passing set, so read the shape rather than the multiple: single-digit counts per cohort, and moving two entries would break the monotonicity. Opus claims a repository at a host that no longer exists. GnuPG's vulnerability-reporting URL times out. Scraps has had no commits since 2020 and still shows &lt;code&gt;maintained&lt;/code&gt; as Met, which is an answer it never gave: that criterion arrived in 2021, five years after its last save.&lt;/p&gt;

&lt;p&gt;Staleness is not the only route in, either. One entry edited the day before I looked points its homepage at a host that does not resolve, and another holds passing with a &lt;code&gt;repo_url&lt;/code&gt; that is a dead link to a foundation page rather than a repository.&lt;/p&gt;

&lt;p&gt;The sharper failure in that table is not decay. Five entries serve a passing badge while claiming &lt;code&gt;sites_https&lt;/code&gt; is Met with an &lt;code&gt;http://&lt;/code&gt; URL, which is the exact example the maintainers' own documentation gives of a claim the app will not accept. None of them has been edited since 2016, so those answers were wrong when they were saved rather than rotting later. The check that would strip them today was written in April 2016, before all five saves, but I could not confirm from the commit history whether it was wired into the save path by then. It is also three people rather than five: one of them filed three near-identical entries thirty-three seconds apart.&lt;/p&gt;

&lt;p&gt;One thing does re-check you, and it happens without you. When the maintainers change a criterion, a bulk task re-scores every project's stored answers against the new rules, and it writes with &lt;code&gt;touch: false&lt;/code&gt;, so your &lt;code&gt;updated_at&lt;/code&gt; never moves. On 11 April 2021 nine projects lost their passing badge inside a 72-second window. Two of them had not been touched by their owners since 2016 and 2017.&lt;/p&gt;

&lt;p&gt;So revocation exists, and it fires when the maintainers change a criterion. Nothing fires when a project simply goes quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintainer already said all this
&lt;/h2&gt;

&lt;p&gt;In March 2024 somebody &lt;a href="https://github.com/ossf/best-practices-badge/issues/2117" rel="noopener noreferrer"&gt;opened an issue&lt;/a&gt; pointing out that dead projects sit in the badge table looking healthy. Wheeler replied:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Also: We do need a way to withdraw badges if no one's checked it in a long time. The "obvious" way to do it is to add a passing criteria like "badge answers have been validated within the last 3 years by a badge applicant". That means every 3 years someone the badge will drop unless someone does something.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reporter agreed it was a good idea. Then nothing was built. The issue is still open, the roadmap has no expiry item, and it opens by describing the project as "essentially in a 'sustainment' mode."&lt;/p&gt;

&lt;p&gt;One related mechanism did ship, in 2021: a new criterion reading "The project MUST be maintained," added outside the usual yearly cycle. The working-group minutes explain the design choice plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;it doesn't risk existing badges because the default value is 'Met'&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The criterion that exists to catch abandoned projects defaults to claiming they are fine, and the automatic detection Wheeler sketched alongside it was never implemented.&lt;/p&gt;

&lt;p&gt;The program diagnosed itself correctly, wrote the roadmap entry calling itself sustainment mode, and has not built the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who comes back
&lt;/h2&gt;

&lt;p&gt;The write-once pattern is not uniform, and the split is sharper than I expected.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;top tier reached&lt;/th&gt;
&lt;th&gt;entries&lt;/th&gt;
&lt;th&gt;last edit within a day of earning&lt;/th&gt;
&lt;th&gt;median gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;passing only&lt;/td&gt;
&lt;td&gt;2,820&lt;/td&gt;
&lt;td&gt;2,278 (80.8%)&lt;/td&gt;
&lt;td&gt;0 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;silver, not gold&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;td&gt;78 (44.3%)&lt;/td&gt;
&lt;td&gt;3 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gold&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;29 (33.7%)&lt;/td&gt;
&lt;td&gt;48 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tier a project chases tracks whether it comes back, and I do not think that is virtue. Gold's gates are staffing gates, so gold holders are by definition projects with more than one person available to do the coming back.&lt;/p&gt;

&lt;p&gt;Forty-four entries have been edited more than three years after earning passing and also within the last year. The long-horizon group names itself: curl, the badge site's own app, the Linux kernel, GitLab, OpenSSL, Weblate, Jaeger. curl first passed in March 2016 and last edited in May 2026, ten years apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who requires it, and does that work?
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/cncf/toc/blob/main/.github/ISSUE_TEMPLATE/template-graduation-application.md" rel="noopener noreferrer"&gt;CNCF requires a passing badge&lt;/a&gt; for both incubation and graduation. The Academy Software Foundation, LF AI &amp;amp; Data, LF Energy and FINOS each set their own rung.&lt;/p&gt;

&lt;p&gt;Then reality. Two CNCF incubating projects have no badge entry at all. Kubeflow's umbrella entry sits at 88%, below passing. Not one graduated Academy Software Foundation project displays gold, although that one is unfair to score: their rule is gold-minus-ten-exemptions, and the site has no way to show that variant, so those projects correctly display passing.&lt;/p&gt;

&lt;p&gt;A mandate gets you an entry. It does not get you a maintained one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which ladder, and where Scorecard fits
&lt;/h2&gt;

&lt;p&gt;Most people who have heard of any of this are thinking of &lt;a href="https://github.com/ossf/scorecard" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt;, which is a different tool: it scans your repository and gives you a score out of ten without asking you anything.&lt;/p&gt;

&lt;p&gt;I picked ten projects to see how far apart the two can get, so read this as an existence proof rather than a correlation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;project&lt;/th&gt;
&lt;th&gt;badge&lt;/th&gt;
&lt;th&gt;badge updated&lt;/th&gt;
&lt;th&gt;Scorecard&lt;/th&gt;
&lt;th&gt;scanned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;curl/curl&lt;/td&gt;
&lt;td&gt;gold&lt;/td&gt;
&lt;td&gt;2026-05-13&lt;/td&gt;
&lt;td&gt;6.9&lt;/td&gt;
&lt;td&gt;2022-11-09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubernetes/kubernetes&lt;/td&gt;
&lt;td&gt;passing&lt;/td&gt;
&lt;td&gt;2017-08-16&lt;/td&gt;
&lt;td&gt;7.6&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rails/rails&lt;/td&gt;
&lt;td&gt;in progress, 94%&lt;/td&gt;
&lt;td&gt;2020-10-15&lt;/td&gt;
&lt;td&gt;6.3&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nodejs/node&lt;/td&gt;
&lt;td&gt;passing&lt;/td&gt;
&lt;td&gt;2024-05-07&lt;/td&gt;
&lt;td&gt;6.1&lt;/td&gt;
&lt;td&gt;2026-07-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;facebook/react&lt;/td&gt;
&lt;td&gt;in progress, 81%&lt;/td&gt;
&lt;td&gt;2018-02-19&lt;/td&gt;
&lt;td&gt;7.0&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ossf/best-practices-badge&lt;/td&gt;
&lt;td&gt;gold&lt;/td&gt;
&lt;td&gt;2026-07-18&lt;/td&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;2026-07-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ossf/scorecard&lt;/td&gt;
&lt;td&gt;passing&lt;/td&gt;
&lt;td&gt;2023-09-19&lt;/td&gt;
&lt;td&gt;8.7&lt;/td&gt;
&lt;td&gt;2026-07-25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;thephpleague/commonmark&lt;/td&gt;
&lt;td&gt;gold&lt;/td&gt;
&lt;td&gt;2019-06-20&lt;/td&gt;
&lt;td&gt;5.1&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;daurnimator/lua-http&lt;/td&gt;
&lt;td&gt;passing&lt;/td&gt;
&lt;td&gt;2018-08-26&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FoveaCentral/google_maps_geocoder&lt;/td&gt;
&lt;td&gt;passing&lt;/td&gt;
&lt;td&gt;2024-11-12&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;2026-07-30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scorecard last looked at curl in November 2022 and re-scanned most of the others this week.&lt;/p&gt;

&lt;p&gt;commonmark holds gold on every tier and scores 5.1. React has no badge worth speaking of and scores 7.0. The disagreement runs both ways, which is the point.&lt;/p&gt;

&lt;p&gt;The two tools are not independent: Scorecard includes your badge as one of its own checks. It fetches bestpractices.dev directly. But it is a low-weighted check, and removing it entirely moved these ten scores by between 0.0 and 0.3.&lt;/p&gt;

&lt;p&gt;The clearest writing I found on either tool is the maintainers' &lt;a href="https://github.com/ossf/best-practices-badge/blob/main/docs/badge-vs-scorecards.md" rel="noopener noreferrer"&gt;own comparison&lt;/a&gt;, which concedes both sides:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scorecards: This is &lt;em&gt;entirely&lt;/em&gt; automated. Pros: You can evaluate &lt;em&gt;any&lt;/em&gt; OSS project with it, immediately. Cons: It focuses on what can be automatically measured (not necessarily what's important...)&lt;/p&gt;

&lt;p&gt;Best practices badge: this requires projects to fill in a form. Pros: It tries to focus on what's important (not what's easily automated); it can handle arbitrary situations &amp;amp; hosting platforms. Cons: It requires projects to work with it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Scorecard can tell you whether branch protection is on, which no questionnaire can verify. The badge can ask whether you fix medium-severity vulnerabilities within 60 days, which no scanner can observe. Scorecard also re-runs itself, in principle: its published data for curl is from November 2022, so automated is not the same as current either.&lt;/p&gt;

&lt;p&gt;If you are starting today: the metal ladder if you want the full interrogation, Baseline if you want the MUST-only subset that foundations are beginning to reference. FINOS already mandates Baseline rather than the metal ladder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ruby corner
&lt;/h2&gt;

&lt;p&gt;I went looking for my own ecosystem and it is thin. 105 entries list Ruby as their primary language: 1 gold, 1 silver, 18 passing, 85 still in progress.&lt;/p&gt;

&lt;p&gt;The single Ruby gold is BadgeApp, the badge site itself. A Rails application, it holds gold on every tier including all three Baseline levels, and it is the only Ruby project on that list I would call well known.&lt;/p&gt;

&lt;p&gt;Rails opened an entry in November 2015. It sits at 94%, has never earned the badge, and was last touched in October 2020. Ruby itself sits at 16%, edited exactly once, the day after the entry was created.&lt;/p&gt;

&lt;p&gt;No entry at all for rubygems, rspec, rubocop, sidekiq, puma, devise, jekyll, rack, sinatra, hanami, or anything under hotwired. If you maintain a Ruby library and want a genuinely uncrowded thing to do this weekend, there it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does any of it make software safer?
&lt;/h2&gt;

&lt;p&gt;No study has measured badge status against security outcomes, and I went looking through the obvious places: Scholar, arXiv, the OpenSSF's own writing, and the foundation reports that track this sort of thing. &lt;a href="https://arxiv.org/abs/2210.14884" rel="noopener noreferrer"&gt;The paper people reach for&lt;/a&gt; studies OpenSSF Scorecard rather than the badge, on 2,422 npm and PyPI packages, and its headline finding points the opposite way from how it gets summarised: vulnerability counts went &lt;em&gt;up&lt;/em&gt; as the aggregate security score went up, at an R-squared of 0.04. There is a paper often cited as an independent study of the badge criteria. It turns out to be the text of a 2016 LWN article.&lt;/p&gt;

&lt;p&gt;Only 16 of the 67 passing criteria are security criteria. The rest are about whether the project is run in a way a stranger can participate in.&lt;/p&gt;

&lt;p&gt;So: the badge measures whether you did a specific set of sensible things once. Whether that makes your software safer is an open question that ten years of data has not been pointed at.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read one, and whether to get one
&lt;/h2&gt;

&lt;p&gt;Read the date before you read the level. It is on the project page, and the badge image will never tell you.&lt;/p&gt;

&lt;p&gt;You can check any project in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s1"&gt;'https://www.bestpractices.dev/projects.json?url=https://github.com/kubernetes/kubernetes'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | "\(.name): \(.badge_level), last updated \(.updated_at[0:10])"'&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;Kubernetes: passing, last updated 2017-08-16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-L&lt;/code&gt; matters: the API redirects to a URL-encoded form of your query, and without it you get an empty response and no error.&lt;/p&gt;

&lt;p&gt;Two readers should skip it. If you want a signal that a project is healthy today, this is not that, and nothing on the site is. And if you are alone on your project, know where the ceiling is before you spend the day: silver costs you a written justification, and gold is closed to you outright. The Baseline ladder has no such gate, which is one practical reason to prefer it.&lt;/p&gt;

&lt;p&gt;For everyone else, the honest case for filling one in is not the image. It is the questions. Ten years of public argument went into choosing them, the first pull request against them was from Greg Kroah-Hartman, and answering them costs a day if you go by the one maintainer who timed it. The badge at the end is a receipt for that day and nothing more.&lt;/p&gt;

&lt;p&gt;Which is fine, as long as you read it as one.&lt;/p&gt;

&lt;p&gt;Just put the re-check in your own calendar. The site will not send you one.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>security</category>
      <category>devops</category>
      <category>ruby</category>
    </item>
    <item>
      <title>Ruby shipped the fix for SleeperGem 45 days before it happened</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Tue, 21 Jul 2026 17:27:49 +0000</pubDate>
      <link>https://dev.to/svyatov/ruby-shipped-the-fix-for-sleepergem-45-days-before-it-happened-19dh</link>
      <guid>https://dev.to/svyatov/ruby-shipped-the-fix-for-sleepergem-45-days-before-it-happened-19dh</guid>
      <description>&lt;p&gt;On June 3rd, Bundler 4.0.13 shipped &lt;a href="https://blog.rubygems.org/2026/06/03/cooldown-let-new-gems-be-vetted.html" rel="noopener noreferrer"&gt;a feature called cooldown&lt;/a&gt;. The release post described the problem it solves like this: "an account is compromised, a malicious version ships, and any &lt;code&gt;bundle install&lt;/code&gt; in the minutes that follow resolves straight to it."&lt;/p&gt;

&lt;p&gt;Forty-five days later, someone did exactly that to three gems.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.aikido.dev/blog/sleepergem-rubygems-supply-chain-attack" rel="noopener noreferrer"&gt;Aikido Security disclosed the campaign&lt;/a&gt; on July 19th and named it SleeperGem: malicious versions of &lt;code&gt;git_credential_manager&lt;/code&gt;, &lt;code&gt;Dendreo&lt;/code&gt;, and &lt;code&gt;fastlane-plugin-run_tests_firebase_testlab&lt;/code&gt;. Two of those gems had sat untouched since 2019 and 2020, which is where the name comes from. (The third impersonates Microsoft's Git Credential Manager, and no report says whether that account was hijacked or simply the attacker's.)&lt;/p&gt;

&lt;p&gt;The clever part is what the payload does when it lands. Aikido found a &lt;code&gt;skip_install?&lt;/code&gt; check that "scans for roughly 30 environment variables belonging to CI platforms, GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins, Vercel, and does nothing if it spots one." It sits still in your pipeline and wakes up on your laptop. &lt;a href="https://www.stepsecurity.io/blog/sleepergem-compromised-rubygems-drop-persistent-backdoor" rel="noopener noreferrer"&gt;StepSecurity reversed the native binary&lt;/a&gt; it drops: a daemon in &lt;code&gt;~/.local/share/gcm/&lt;/code&gt;, a systemd user unit, a cron entry, and if you installed as root, a setuid shell at &lt;code&gt;/usr/local/sbin/ping6&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Most of the coverage stopped at "supply chain attack, be careful out there," then repeated numbers that do not survive a look at the registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers being repeated are not the numbers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://thehackernews.com/2026/07/sleepergem-uses-three-malicious.html" rel="noopener noreferrer"&gt;The most-syndicated writeup&lt;/a&gt; lists &lt;code&gt;Dendreo&lt;/code&gt; versions 1.1.3 and 1.1.4 as "Published on October 14, 2017." The registry says 2017-10-14 is version &lt;strong&gt;1.0.1&lt;/strong&gt;'s date, and that 1.1.2 shipped in October 2020. A 1.1.3 published three years before 1.1.2 is not a date, it is a copy-paste of the gem's first release. Same error on the fastlane plugin, where "February 06, 2018" belongs to 0.1.0. Both malicious releases went up in July 2026. That is the article the aggregators spent the next day copying.&lt;/p&gt;

&lt;p&gt;The scale number has the same shape. Aikido's "574,661 total downloads" is honest in context (it says all versions), but downstream it sits next to the attack and reads like blast radius. Here is where they live:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;fastlane-plugin-run_tests_firebase_testlab&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;published&lt;/th&gt;
&lt;th&gt;downloads&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.3.1&lt;/td&gt;
&lt;td&gt;2019-03-04&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;531,859&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;################################&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2.0&lt;/td&gt;
&lt;td&gt;2018-07-17&lt;/td&gt;
&lt;td&gt;16,509&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1.0&lt;/td&gt;
&lt;td&gt;2018-02-06&lt;/td&gt;
&lt;td&gt;10,619&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2.2&lt;/td&gt;
&lt;td&gt;2018-11-29&lt;/td&gt;
&lt;td&gt;6,323&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3.0&lt;/td&gt;
&lt;td&gt;2019-01-11&lt;/td&gt;
&lt;td&gt;4,965&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2.1&lt;/td&gt;
&lt;td&gt;2018-09-27&lt;/td&gt;
&lt;td&gt;4,892&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;0.3.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026-07-19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yanked, count deleted&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One benign release from 2019 is 92.5% of it. &lt;code&gt;Dendreo&lt;/code&gt; has 14,435 downloads across its entire seven-year life. Nobody publishes install counts for the malicious versions, and they can't: yanking a version removes it from the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mitigation was already on the shelf
&lt;/h2&gt;

&lt;p&gt;Cooldown makes Bundler refuse to resolve to a release younger than N days. The first malicious version went up on July 18th, and all of them were gone from the registry by the 21st. Nobody published the actual yank time, so three days is an upper bound, not a measurement, but a cooldown of seven would have skipped every one of them for as long as they existed.&lt;/p&gt;

&lt;p&gt;It is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"https://rubygems.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;cooldown: &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked it against a recent release. &lt;code&gt;concurrent-ruby&lt;/code&gt; 1.3.8 went up on July 19th, the same day SleeperGem's last gem did:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gemfile&lt;/th&gt;
&lt;th&gt;resolves to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source "https://rubygems.org"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;concurrent-ruby &lt;strong&gt;1.3.8&lt;/strong&gt; (2 days old)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source "https://rubygems.org", cooldown: 7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;concurrent-ruby &lt;strong&gt;1.3.7&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It also fails closed. If nothing in the registry is old enough to satisfy your cooldown, Bundler does not quietly install the newest version anyway. I forced that case with an absurd window, &lt;code&gt;cooldown: 36500&lt;/code&gt;, about a hundred years, against &lt;code&gt;concurrent-ruby&lt;/code&gt;: it refused and told me what it held back, &lt;code&gt;79 versions excluded by the cooldown setting; pass --cooldown 0 to bypass&lt;/code&gt;. A guard that gives up silently when it cannot be satisfied is worse than no guard, because you think you have one.&lt;/p&gt;

&lt;p&gt;Cooldown is unset by default, so if you never added that line, June changed nothing for you. None of the SleeperGem coverage I can find mentions that the feature exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't buy you
&lt;/h2&gt;

&lt;p&gt;Cooldown is a delay, not a defense. It works here because this attacker was loud and got caught in days. Against a version that ages quietly for three weeks, seven days of patience buys nothing.&lt;/p&gt;

&lt;p&gt;It also does nothing about the actual hole. Neither Aikido nor StepSecurity says how the accounts were taken over. No vector, no phishing story, no statement about MFA in either direction. So every "this is why we need mandatory 2FA" reply is guessing, and mandatory MFA on RubyGems kicks in at 180 million downloads anyway. These gems are at 0.32% and 0.008% of that. It was never going to apply.&lt;/p&gt;

&lt;p&gt;RubyGems built the right thing early and left it switched off. Go switch it on.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>security</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Rails already ships class_names. I built clsx anyway.</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/svyatov/rails-already-ships-classnames-i-built-clsx-anyway-5h4h</link>
      <guid>https://dev.to/svyatov/rails-already-ships-classnames-i-built-clsx-anyway-5h4h</guid>
      <description>&lt;p&gt;When I published a little gem for conditional CSS classes two years ago, the first comment was Adrien Poly asking the obvious thing: "What is the main difference with rails class_names helper?" My honest reply at the time: I didn't know &lt;code&gt;class_names&lt;/code&gt; existed. I had written a gem for something Rails already shipped.&lt;/p&gt;

&lt;p&gt;So I went and looked, guessed the difference was performance, and left it there. That answer bugged me for two years. Here is the real one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;class_names&lt;/code&gt; is a good view helper. It also only ships with ActionView, it spills a few inputs, it is slower than it needs to be, and it has no idea that &lt;code&gt;px-2&lt;/code&gt; and &lt;code&gt;px-4&lt;/code&gt; are the same knob. None of those matter until they do. When they did, for me, the fix turned one small Rails gem into two: &lt;a href="https://github.com/svyatov/clsx-ruby" rel="noopener noreferrer"&gt;clsx-ruby&lt;/a&gt;, a framework-agnostic core, and &lt;a href="https://github.com/svyatov/clsx-rails" rel="noopener noreferrer"&gt;clsx-rails&lt;/a&gt;, a thin adapter over it.&lt;/p&gt;

&lt;p&gt;This is the tour I wish I could have given Adrien in that comment thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, credit where it is due
&lt;/h2&gt;

&lt;p&gt;If you are on Rails 6.1 or later, you can build a class string conditionally today, no gems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;div&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="n"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"btn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"btn-primary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;active: &lt;/span&gt;&lt;span class="vi"&gt;@active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  Click me
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;class_names&lt;/code&gt; is a public alias for &lt;code&gt;token_list&lt;/code&gt; in &lt;code&gt;ActionView::Helpers::TagHelper&lt;/code&gt;. It takes strings, arrays, and hashes, splits on whitespace, drops falsy values, dedups the tokens, and hands back an HTML-safe string. For the everyday cases it is genuinely fine, and clsx agrees with it token for token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"clsx"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_view"&lt;/span&gt;
&lt;span class="c1"&gt;# In an ERB template you call class_names(...) bare; here we mix it into an object.&lt;/span&gt;
&lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ActionView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Helpers&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TagHelper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# =&amp;gt; "a b"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                             &lt;span class="c1"&gt;# =&amp;gt; "a b"&lt;/span&gt;

&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;foo: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;bar: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# =&amp;gt; "foo"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;foo: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;bar: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                &lt;span class="c1"&gt;# =&amp;gt; "foo"&lt;/span&gt;

&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"b c"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# =&amp;gt; "a b c"   (dedups across tokens)&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"a b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"b c"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                         &lt;span class="c1"&gt;# =&amp;gt; "a b c"&lt;/span&gt;

&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;        &lt;span class="c1"&gt;# =&amp;gt; "a b c"   (flattens nesting)&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;]]]&lt;/span&gt;                    &lt;span class="c1"&gt;# =&amp;gt; "a b c"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if that is all you need, you need nothing. Use the built-in. I mean it.&lt;/p&gt;

&lt;p&gt;The rest of this post is about the four places that "all you need" runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 1: it comes bundled with ActionView
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;class_names&lt;/code&gt; is a method on &lt;code&gt;ActionView::Helpers::TagHelper&lt;/code&gt;. It does not actually need a live view, no request, no output buffer. It needs the module. But the module is part of ActionView, so to join two strings in a Sinatra app or a plain service object, you load a whole view framework and mix it in by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In a plain object with nothing required, class_names is not defined:&lt;/span&gt;
&lt;span class="n"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;active: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; NoMethodError&lt;/span&gt;

&lt;span class="c1"&gt;# You pull ActionView in and extend the module onto an object:&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"action_view"&lt;/span&gt;
&lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ActionView&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Helpers&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TagHelper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;active: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "a"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the itch that started the split. I had put the class builder inside a Rails gem because Rails is where I was working that week. Then I wanted the same thing in a Phlex component, and in a plain presenter object, and reaching for ActionView to get it felt absurd. There is nothing Rails-specific about joining class names. So I pulled the logic into clsx-ruby, one file, about 130 lines, zero runtime dependencies, and left clsx-rails as a shim on top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"clsx"&lt;/span&gt;

&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"btn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"btn-primary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;active: &lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;disabled: &lt;/span&gt;&lt;span class="n"&gt;is_disabled&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; "btn btn-primary active"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reason 2: empty results, and what happens on garbage
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;class_names&lt;/code&gt; and clsx agree on the common cases. The one difference I lean on daily is what they return when nothing applies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; ""    (renders class="")&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;               &lt;span class="c1"&gt;# =&amp;gt; nil   (Rails tag helpers skip it)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rails skips a &lt;code&gt;nil&lt;/code&gt; class attribute, so &lt;code&gt;Clsx[...]&lt;/code&gt; never renders an empty &lt;code&gt;class=""&lt;/code&gt; on a component that had nothing to add. &lt;code&gt;class_names&lt;/code&gt; gives you &lt;code&gt;""&lt;/code&gt;, which renders.&lt;/p&gt;

&lt;p&gt;There is one honest point the other way. &lt;code&gt;class_names&lt;/code&gt; returns an &lt;code&gt;html_safe&lt;/code&gt; string; clsx returns a plain &lt;code&gt;String&lt;/code&gt;. Inside a &lt;code&gt;class:&lt;/code&gt; attribute that is a wash, Rails escapes the value either way and class tokens have nothing to escape. If you interpolate clsx output raw somewhere else, it is a normal string and gets escaped like one, which is the safe default, not a footgun. But if you specifically want &lt;code&gt;html_safe&lt;/code&gt; output, &lt;code&gt;class_names&lt;/code&gt; hands it to you and clsx does not.&lt;/p&gt;

&lt;p&gt;The rest is robustness. Neither of these inputs is something you write on purpose, but a helper somewhere eventually hands the builder something weird, and the two disagree on what to do with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A complex value used as a hash key.&lt;/span&gt;
&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="ss"&gt;foo: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# =&amp;gt; "[{foo: true}, &amp;amp;quot;bar&amp;amp;quot;]"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[[{&lt;/span&gt; &lt;span class="ss"&gt;foo: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;              &lt;span class="c1"&gt;# =&amp;gt; "foo bar"&lt;/span&gt;

&lt;span class="c1"&gt;# An uncalled lambda and a stray boolean.&lt;/span&gt;
&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;proc&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# =&amp;gt; "#&amp;amp;lt;Proc:0x...&amp;amp;gt; true"   (escaped into your markup)&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;proc&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;              &lt;span class="c1"&gt;# =&amp;gt; nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;class_names&lt;/code&gt; stringifies whatever it gets and escapes it into the attribute; clsx resolves a complex key recursively and ignores a bare &lt;code&gt;Proc&lt;/code&gt; or &lt;code&gt;true&lt;/code&gt;. When an &lt;code&gt;admin_link&lt;/code&gt; helper accidentally hands you an uncalled lambda, clsx drops it and &lt;code&gt;class_names&lt;/code&gt; prints a &lt;code&gt;Proc&lt;/code&gt;'s address into your HTML. (There is also a cosmetic gap: &lt;code&gt;class_names("  a\tb  ")&lt;/code&gt; keeps a leading empty token, &lt;code&gt;" a b"&lt;/code&gt;; clsx normalizes to &lt;code&gt;"a b"&lt;/code&gt;. Browsers ignore the extra space, so this one is tidiness, not a bug.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The full vocabulary
&lt;/h2&gt;

&lt;p&gt;The name is a homage: clsx-ruby is modeled on &lt;a href="https://github.com/lukeed/clsx" rel="noopener noreferrer"&gt;lukeed's &lt;code&gt;clsx&lt;/code&gt;&lt;/a&gt;, the tiny JavaScript package, so the argument model lines up with what React developers already know. Since the differences above are really about what counts as a class and what counts as truthy, here are the rules in one place. clsx takes strings, symbols, numbers, hashes, arrays, and any nesting of those:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"foo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"baz"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# =&amp;gt; "foo bar baz"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:foo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:"bar-baz"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                      &lt;span class="c1"&gt;# =&amp;gt; "foo bar-baz"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                               &lt;span class="c1"&gt;# =&amp;gt; "1 2 3"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s2"&gt;"foo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;            &lt;span class="c1"&gt;# =&amp;gt; "foo bar"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"foo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;baz: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"there"&lt;/span&gt;&lt;span class="p"&gt;]]]]&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; "foo bar hi there"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hash includes each key whose value is truthy, in declaration order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;foo: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;bar: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;baz: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "foo baz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one rule that trips up people coming from JavaScript: in Ruby only &lt;code&gt;false&lt;/code&gt; and &lt;code&gt;nil&lt;/code&gt; are falsy. So &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;""&lt;/code&gt;, &lt;code&gt;[]&lt;/code&gt;, and &lt;code&gt;{}&lt;/code&gt; are all truthy and all keep their key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"foo"&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="ss"&gt;bar: &lt;/span&gt;&lt;span class="p"&gt;[]]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "foo bar"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And two conveniences the JS &lt;code&gt;clsx&lt;/code&gt; does not have. Duplicates are removed even across multi-token strings, and an empty result is &lt;code&gt;nil&lt;/code&gt;, not &lt;code&gt;""&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"foo bar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"foo"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "foo bar"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"   "&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;              &lt;span class="c1"&gt;# =&amp;gt; nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line above is a real result. &lt;code&gt;bundle add clsx-ruby&lt;/code&gt;, drop them into a REPL, and check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 3: it is slower, and here is the honest version of that
&lt;/h2&gt;

&lt;p&gt;clsx is faster than &lt;code&gt;class_names&lt;/code&gt; per call. I reproduced it against the real &lt;code&gt;ActionView&lt;/code&gt; helper (not a stand-in), timing only the cases where both produce identical output, on Ruby 4.0.6 and an M1 Pro:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;clsx-ruby&lt;/th&gt;
&lt;th&gt;Rails &lt;code&gt;class_names&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token&lt;/td&gt;
&lt;td&gt;5.1M i/s&lt;/td&gt;
&lt;td&gt;674K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long utility&lt;/td&gt;
&lt;td&gt;572K i/s&lt;/td&gt;
&lt;td&gt;116K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility string&lt;/td&gt;
&lt;td&gt;1.2M i/s&lt;/td&gt;
&lt;td&gt;248K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple strings&lt;/td&gt;
&lt;td&gt;1.4M i/s&lt;/td&gt;
&lt;td&gt;334K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;String array&lt;/td&gt;
&lt;td&gt;1.2M i/s&lt;/td&gt;
&lt;td&gt;307K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash&lt;/td&gt;
&lt;td&gt;1.5M i/s&lt;/td&gt;
&lt;td&gt;475K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.2x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;String + hash&lt;/td&gt;
&lt;td&gt;1.0M i/s&lt;/td&gt;
&lt;td&gt;365K i/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is one representative run; the geometric mean held around 4x across three. The interesting number is not the 7.5x on a lone token, which is the least realistic input anyone would reach for a builder to handle, it is the 2.9x on the busiest, most Tailwind-like input. The speedup shrinks as the work grows, so the low end is the honest one.&lt;/p&gt;

&lt;p&gt;A caveat on that gap. The gem's README quotes a more conservative 2 to 4x, because it benchmarks against a dependency-free replica of &lt;code&gt;class_names&lt;/code&gt;. Part of the extra margin here is not a faster join, it is work clsx skips: &lt;code&gt;class_names&lt;/code&gt; builds an &lt;code&gt;html_safe&lt;/code&gt; buffer and un-escapes HTML on every call, and my identical-output filter compares string content, not that safety step. So the pure algorithm-to-algorithm number is the README's 2 to 4x; the wider figure is end-to-end against the real helper, which is what a Rails view actually pays. The gem ships its &lt;a href="https://github.com/svyatov/clsx-ruby/blob/main/benchmark/vs_rails.rb" rel="noopener noreferrer"&gt;benchmark&lt;/a&gt;; run it yourself.&lt;/p&gt;

&lt;p&gt;Now the honest part. This will almost never make a page faster. A class builder runs a handful of times per request, and your render time lives somewhere else entirely: allocations, HTML escaping, partial lookup, the N+1 you have not found yet. A 4x speedup on 0.1% of the work rounds to nothing.&lt;/p&gt;

&lt;p&gt;Where it actually shows up is call count. Render a table with 5,000 rows and three conditional-class calls each, and now the helper runs 15,000 times in one response. That is the case where "4x faster per call" stops being a micro-benchmark and starts being real milliseconds. If your views do not do that, treat the speed as a nice-to-have and pick clsx for the other three reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 4: it cannot merge Tailwind classes
&lt;/h2&gt;

&lt;p&gt;This is the one neither &lt;code&gt;class_names&lt;/code&gt; nor plain clsx can touch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class_names&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"px-2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"px-4"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "px-2 px-4"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"px-2 px-4"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                  &lt;span class="c1"&gt;# =&amp;gt; "px-2 px-4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both keep both classes, because neither knows that &lt;code&gt;px-2&lt;/code&gt; and &lt;code&gt;px-4&lt;/code&gt; are the same knob set twice. And in Tailwind, shipping both is a bug you cannot see. The winner is not the last class in the attribute, it is whichever rule Tailwind emitted later in the compiled stylesheet, and Tailwind orders by its own scale, not by what you wrote. That is deterministic, so it will not flip between machines, but it is also not your call: you ship a silently dead class, and which one dies is decided by Tailwind's internal ordering rather than your intent.&lt;/p&gt;

&lt;p&gt;If you have written React with Tailwind, you already know the fix, even if you have never looked at it. That &lt;code&gt;cn(...)&lt;/code&gt; helper everyone copies out of shadcn/ui is literally &lt;code&gt;twMerge(clsx(inputs))&lt;/code&gt;: clsx builds the string, &lt;code&gt;tailwind-merge&lt;/code&gt; collapses the conflicts so the class you wrote last is the one that survives. clsx does the same, split into two functions so you only pay for merging when you ask for it.&lt;/p&gt;

&lt;p&gt;The merge is opt-in. clsx-ruby stays dependency-free; you add the &lt;a href="https://github.com/gjtorikian/tailwind_merge" rel="noopener noreferrer"&gt;&lt;code&gt;tailwind_merge&lt;/code&gt;&lt;/a&gt; gem and wire it up once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"tailwind_merge"&lt;/span&gt;

&lt;span class="c1"&gt;# config/initializers/clsx.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"clsx/tailwind_merge"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;TailwindMerge&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Merger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That adds &lt;code&gt;twm&lt;/code&gt; (and &lt;code&gt;Twm[]&lt;/code&gt;, and &lt;code&gt;Clsx.twm&lt;/code&gt;) alongside &lt;code&gt;clsx&lt;/code&gt;. &lt;code&gt;clsx&lt;/code&gt; and &lt;code&gt;cn&lt;/code&gt; stay pure; only &lt;code&gt;twm&lt;/code&gt; merges, and the last one you wrote wins, whichever order you wrote it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"px-2 px-4"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                          &lt;span class="c1"&gt;# =&amp;gt; "px-4"&lt;/span&gt;
&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"px-4 px-2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                          &lt;span class="c1"&gt;# =&amp;gt; "px-2"   (written order, not Tailwind's)&lt;/span&gt;
&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"p-4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"p-2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"bg-red"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"bg-blue"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c1"&gt;# =&amp;gt; "p-2 bg-blue"&lt;/span&gt;
&lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"px-2 px-4"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                         &lt;span class="c1"&gt;# =&amp;gt; "px-2 px-4"   (unchanged)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line is the whole point: your written order becomes authoritative, which is exactly what raw Tailwind does not honor. It is also group-aware, so it only collapses classes that genuinely conflict. &lt;code&gt;px-3&lt;/code&gt; and &lt;code&gt;pr-4&lt;/code&gt; set different things, so both survive, but &lt;code&gt;px-3&lt;/code&gt; is the broader knob (it includes padding-right), so written last it wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"px-3 pr-4"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "px-3 pr-4"   (different groups, both kept)&lt;/span&gt;
&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"pr-4 px-3"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "px-3"        (px-3 covers pr, and it came last)&lt;/span&gt;
&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"px-[13px] px-[14px]"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "px-[14px]"   (arbitrary values too)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because &lt;code&gt;twm&lt;/code&gt; takes the same inputs as &lt;code&gt;clsx&lt;/code&gt;, you get the conditional syntax and the merge in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Twm&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s2"&gt;"px-2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"rounded"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"px-4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"px-6"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; "rounded px-6"   (active)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You set &lt;code&gt;Clsx.merger&lt;/code&gt; once at boot and read it per request. Merging is real work: &lt;code&gt;tailwind_merge&lt;/code&gt; parses each class into its group, which is why it keeps a result cache and why it is worth doing only when asked. That is also why I kept &lt;code&gt;clsx&lt;/code&gt; and &lt;code&gt;twm&lt;/code&gt; separate. An early prototype had a global switch that routed every call through the merger (never shipped, so do not go looking for it), and I talked myself out of it: most calls have no conflict to resolve, so paying the merge cost on all of them to help a few is the wrong default.&lt;/p&gt;

&lt;p&gt;The merger slot is pluggable, it just has to respond to &lt;code&gt;merge&lt;/code&gt;. The default is Garen Torikian's &lt;code&gt;tailwind_merge&lt;/code&gt;, which I use enough that I have &lt;a href="https://github.com/gjtorikian/tailwind_merge/pull/79" rel="noopener noreferrer"&gt;two&lt;/a&gt; &lt;a href="https://github.com/gjtorikian/tailwind_merge/pull/80" rel="noopener noreferrer"&gt;PRs&lt;/a&gt; in it, one making that result cache thread-safe and one speeding up the hot path, both shipped in its 1.5.2. If you need more throughput, a Rust-backed alternative (&lt;code&gt;tail_merge&lt;/code&gt;) exists, though confirm its Tailwind version support against your setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which call do I reach for?
&lt;/h2&gt;

&lt;p&gt;There are a few ways in, which looks like more than it is. They all run the same code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Clsx[...]&lt;/code&gt; and &lt;code&gt;Cn[...]&lt;/code&gt; (the bracket form) work anywhere, no setup. This is what I use in plain Ruby.&lt;/li&gt;
&lt;li&gt;In Rails views (ERB, HAML, or Slim), clsx-rails gives you &lt;code&gt;clsx(...)&lt;/code&gt; and &lt;code&gt;cn(...)&lt;/code&gt; as bare helpers, no include.&lt;/li&gt;
&lt;li&gt;In a ViewComponent, a Phlex component, or any object, &lt;code&gt;include Clsx::Helper&lt;/code&gt; once and you get &lt;code&gt;clsx&lt;/code&gt;/&lt;code&gt;cn&lt;/code&gt; (and &lt;code&gt;twm&lt;/code&gt; if you wired it up) as methods.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Clsx.clsx(...)&lt;/code&gt; / &lt;code&gt;Clsx.cn(...)&lt;/code&gt; are the module-method form for when you do not want either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;cn&lt;/code&gt; is just a shorter alias for &lt;code&gt;clsx&lt;/code&gt;. Pick whichever reads better in the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it outside Rails
&lt;/h2&gt;

&lt;p&gt;The whole point of the split is that none of this needs Rails. In a Phlex component you include the helper once and call it like any method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"phlex"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"clsx"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Badge&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Phlex&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTML&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Clsx&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Helper&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;color: :blue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;pill: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="vi"&gt;@color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;
    &lt;span class="vi"&gt;@pill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pill&lt;/span&gt;
    &lt;span class="vi"&gt;@html_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;binding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;local_variable_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# `class` is a keyword&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;view_template&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;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="n"&gt;clsx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"badge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"badge-&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="vi"&gt;@color&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vi"&gt;@html_class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;pill: &lt;/span&gt;&lt;span class="vi"&gt;@pill&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;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;Badge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;color: :red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;pill: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"badge ml-2"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"New"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; &amp;lt;span class="badge badge-red ml-2 pill"&amp;gt;New&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line in there is pure Ruby trivia. You can name a keyword argument &lt;code&gt;class:&lt;/code&gt;, but you cannot read it back as &lt;code&gt;class&lt;/code&gt; (it is a reserved word, so &lt;code&gt;@html_class = class&lt;/code&gt; is a syntax error). &lt;code&gt;binding.local_variable_get(:class)&lt;/code&gt; is the escape hatch that fetches the local by name.&lt;/p&gt;

&lt;p&gt;The payoff is the cross-argument dedup: &lt;code&gt;badge&lt;/code&gt; appears once even though the caller passed it and the component added it too. That is the thing you actually want when a component merges its classes with a caller's, and it is tedious to write by hand every time. The same include works in a ViewComponent base class, a Sinatra route, or a bare presenter.&lt;/p&gt;

&lt;p&gt;Phlex, for the record, has its own answer for the simple case: &lt;code&gt;class:&lt;/code&gt; accepts an array and compacts falsy entries, and its &lt;code&gt;mix&lt;/code&gt; helper merges attribute hashes. Reach for clsx when you want the hash-conditional syntax or the cross-argument dedup, and &lt;code&gt;twm&lt;/code&gt; when you want Tailwind conflicts resolved, which none of Rails, ViewComponent, or Phlex do on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed since 2024, and do you have to migrate
&lt;/h2&gt;

&lt;p&gt;The old post covered clsx-rails around v1: the basic string, array, and hash syntax, Rails only. Since then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;v2 (2025) rewrote the algorithm for a 2 to 5x speedup and dropped Rails 6.1 and 7.0.&lt;/li&gt;
&lt;li&gt;v3 (2026) extracted the engine into clsx-ruby and made clsx-rails a thin adapter over it. It raised the floor to Ruby 3.2+ and Rails 7.2+.&lt;/li&gt;
&lt;li&gt;v3.1 added the &lt;code&gt;twm&lt;/code&gt; Tailwind merge through clsx-ruby 1.2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are on an older clsx-rails, the upgrade is quiet. The breaking changes in v3 are the Ruby and Rails version floors, not the helpers. Same &lt;code&gt;clsx&lt;/code&gt; and &lt;code&gt;cn&lt;/code&gt;, same output. The one snag: &lt;code&gt;Clsx::VERSION&lt;/code&gt; now reports the clsx-ruby core version, and the adapter's own version moved to &lt;code&gt;Clsx::Rails::VERSION&lt;/code&gt;, so anything that read the old constant needs a one-line update. New since you last looked: everything runs outside Rails now, and &lt;code&gt;twm&lt;/code&gt; exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use something else
&lt;/h2&gt;

&lt;p&gt;clsx builds a class string. That is the whole job. If your problem is bigger, it is the wrong tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You just need conditional classes in a Rails view and none of the four reasons bite? Use &lt;code&gt;class_names&lt;/code&gt;. It is already there.&lt;/li&gt;
&lt;li&gt;You want a variant system, the &lt;code&gt;size: :lg, intent: :danger&lt;/code&gt; to full-class-list mapping React people know as CVA? That is &lt;a href="https://github.com/avo-hq/class_variants" rel="noopener noreferrer"&gt;&lt;code&gt;class_variants&lt;/code&gt;&lt;/a&gt;, a different and larger idea, and it can lean on a merger like &lt;code&gt;twm&lt;/code&gt; for the final conflict pass.&lt;/li&gt;
&lt;li&gt;You want another plain conditional-class helper to compare against? &lt;a href="https://github.com/komposable/klassnames" rel="noopener noreferrer"&gt;&lt;code&gt;klassnames&lt;/code&gt;&lt;/a&gt; is the closest same-purpose gem for Rails; it does the conditional join without the framework-agnostic core, the &lt;code&gt;nil&lt;/code&gt; return, or the Tailwind merge.&lt;/li&gt;
&lt;li&gt;You want the smallest possible dependency and only ever pass plain strings? You can hand-roll a &lt;code&gt;compact.join(" ")&lt;/code&gt; and skip the gem. You lose dedup, the hash syntax, &lt;code&gt;nil&lt;/code&gt; handling, and the merge, but if you never wanted those, that is a fair trade.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everything between "join two strings" and "design a variant DSL," which is most of what view code actually does, clsx is the size that fits.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;class_names&lt;/code&gt; builds class strings inside a view. clsx builds them anywhere, keeps the weird inputs from leaking, and knows what to do when two Tailwind utilities disagree. That is the whole difference, and now it is one &lt;code&gt;bundle add&lt;/code&gt; away.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>tailwindcss</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Learn Big O by measuring it, in Ruby</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Mon, 20 Jul 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/svyatov/learn-big-o-by-measuring-it-in-ruby-217m</link>
      <guid>https://dev.to/svyatov/learn-big-o-by-measuring-it-in-ruby-217m</guid>
      <description>&lt;p&gt;A very common way Ruby code gets slow at scale hides in a line that looks harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;seen&lt;/code&gt; is an Array, that is O(n²). Every one of the &lt;code&gt;n&lt;/code&gt; records triggers an &lt;code&gt;include?&lt;/code&gt; that scans up to &lt;code&gt;n&lt;/code&gt; elements. It runs fine on the hundred rows you tested and falls over on the hundred thousand you didn't. You can see this coming, and you don't have to squint at the code to do it. You can measure the growth rate directly and watch it bend upward.&lt;/p&gt;

&lt;p&gt;That is what this post is about. Not the theory of Big O from a chart of curves, but Big O as something you can put on a clock. We are going to take real Ruby, run it on inputs that keep doubling, time each run, and let the complexity class fall out of the numbers, one at a time. By the end you will have measured O(1), O(log n), O(n), O(n log n), O(n²), and O(2ⁿ) with your own hands. And you will know exactly when the measurement is lying to you, because that turns out to be the harder and more useful half.&lt;/p&gt;

&lt;p&gt;I went looking for a &lt;code&gt;BigO.measure { some_code }&lt;/code&gt; method, half expecting to build it. It already exists, it has been sitting on RubyGems since 2020, and the interesting part is how confidently it can be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole idea: double the input, watch the clock
&lt;/h2&gt;

&lt;p&gt;Big O describes how the work grows as the input grows. Not how long something takes, but how the time &lt;em&gt;scales&lt;/em&gt;. O(n) means double the input and the work roughly doubles. O(n²) means double the input and the work goes up about fourfold. O(1) means the input size doesn't matter at all.&lt;/p&gt;

&lt;p&gt;That "double the input" is the whole measurement technique, and it is older than any gem. It is called the doubling hypothesis, and it is exactly what Princeton teaches in its algorithms course. Run your code at some size &lt;code&gt;n&lt;/code&gt;, then at &lt;code&gt;2n&lt;/code&gt;, and look at the ratio of the two times:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time ratio when you double n&lt;/th&gt;
&lt;th&gt;Growth class&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stays flat (~1×)&lt;/td&gt;
&lt;td&gt;O(1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;creeps up by a constant&lt;/td&gt;
&lt;td&gt;O(log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~2×&lt;/td&gt;
&lt;td&gt;O(n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a bit more than 2×&lt;/td&gt;
&lt;td&gt;O(n log n)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~4×&lt;/td&gt;
&lt;td&gt;O(n²)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;squares each step&lt;/td&gt;
&lt;td&gt;O(2ⁿ)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you do this across a wide range of sizes and fit the points to the curve &lt;code&gt;time = a · n^b&lt;/code&gt;, the exponent &lt;code&gt;b&lt;/code&gt; is the growth rate. Fit a straight line through the same points on log-log axes and its slope is that same &lt;code&gt;b&lt;/code&gt;. That is the entire trick: measure at many sizes, fit a power curve, read the exponent. A linear algorithm gives &lt;code&gt;b ≈ 1&lt;/code&gt;. A quadratic one gives &lt;code&gt;b ≈ 2&lt;/code&gt;. Everything else lands somewhere you can read off a ruler.&lt;/p&gt;

&lt;p&gt;There is a gem that does the fitting for you. Before we let it, let's do it by hand once, so nothing about the result is magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every class, measured
&lt;/h2&gt;

&lt;p&gt;The tool is &lt;a href="https://github.com/piotrmurach/benchmark-trend" rel="noopener noreferrer"&gt;&lt;code&gt;benchmark-trend&lt;/code&gt;&lt;/a&gt; by Piotr Murach. Add it to a Gemfile with &lt;code&gt;gem "benchmark-trend"&lt;/code&gt; and &lt;code&gt;bundle install&lt;/code&gt;. It gives you two pieces: &lt;code&gt;Benchmark::Trend.range&lt;/code&gt; to build a geometric ladder of sizes (with &lt;code&gt;ratio: 4&lt;/code&gt;, each size is four times the last: 1000, 4000, 16000, and so on up to the max), and &lt;code&gt;Benchmark::Trend.infer_trend&lt;/code&gt; to run your code at each size and fit the curves.&lt;/p&gt;

&lt;p&gt;One thing matters before we start, and it is the mistake everyone makes first. &lt;code&gt;infer_trend&lt;/code&gt; times the &lt;em&gt;entire block&lt;/em&gt; you give it. If you build your test data inside that block, you are timing the setup too, and the setup usually has its own complexity that swamps what you meant to measure. So build the input for each size first, then time only the operation. Here is a small helper that does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"benchmark/trend"&lt;/span&gt;

&lt;span class="c1"&gt;# sizes:   the geometric range of input sizes&lt;/span&gt;
&lt;span class="c1"&gt;# prepare: builds the input for a given size, once, before timing&lt;/span&gt;
&lt;span class="c1"&gt;# work:    the operation whose growth we're measuring&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&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;prepare&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;work&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sizes&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="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&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="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;infer_trend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;repeat: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="ss"&gt;:residual&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# how well the winning curve fit (R², 0 to 1)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trend&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:exponential&lt;/span&gt;
    &lt;span class="c1"&gt;# A compounding curve has no polynomial exponent to read, so skip it.&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"best fit: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  (compounding growth)  R2=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%.4f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else&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;fits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:power&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="ss"&gt;:slope&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="c1"&gt;# the power-law exponent: our growth "ruler"&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"best fit: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  exponent b=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%.2f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  R2=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%.4f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two blocks are in play here, and it helps to keep them straight. The one &lt;code&gt;infer_trend&lt;/code&gt; calls receives &lt;code&gt;(n, i)&lt;/code&gt;, the size and its index, and uses &lt;code&gt;i&lt;/code&gt; to pick the matching pre-built input. The block &lt;em&gt;you&lt;/em&gt; write and pass to &lt;code&gt;measure&lt;/code&gt; receives that prepared input as its first argument and the size &lt;code&gt;n&lt;/code&gt; as its second, so you will see signatures like &lt;code&gt;|array, n|&lt;/code&gt; below (and &lt;code&gt;_n&lt;/code&gt; when a demo doesn't need the size). Either way, &lt;code&gt;work.call(...)&lt;/code&gt; is the timed part, never the setup. Save this helper as &lt;code&gt;measure.rb&lt;/code&gt;; every demo below opens with &lt;code&gt;require_relative "measure"&lt;/code&gt;, and each is a complete, runnable file once &lt;code&gt;benchmark-trend&lt;/code&gt; is installed.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;repeat: 4&lt;/code&gt; runs each size four times and averages, to smooth out noise.) Read two numbers out of every run. The &lt;code&gt;best fit&lt;/code&gt; is the curve family the gem picked. The exponent &lt;code&gt;b&lt;/code&gt; is the growth ruler: near 0 is flat, near 1 is linear, near 2 is quadratic. For an exponential curve there is no meaningful polynomial exponent, so the helper skips it and the family name carries the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  O(1): the lookup that doesn't care how big the haystack is
&lt;/h3&gt;

&lt;p&gt;Start with the class that feels like magic the first time you see it. A Hash lookup takes about the same time whether the Hash holds a thousand keys or a million:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt;

&lt;span class="no"&gt;LOOKUPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200_000&lt;/span&gt;
&lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;# Build a Hash of n keys, plus a fixed batch of keys to look up.&lt;/span&gt;
  &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&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="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LOOKUPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&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="nb"&gt;hash&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;hash&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;_n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;hash&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="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# always LOOKUPS lookups, no matter how big the Hash&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The batch is a fixed 200,000 lookups at every size. The Hash grows from a thousand keys to a million; the amount of work does not. Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;best fit: linear  exponent b=0.26  R2=0.9976
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exponent is 0.26, way below linear's 1. Flat-ish. It is not exactly 0, and the leftover is the machine, not the algorithm: most likely the Hash spilling out of CPU cache as it grows, so each lookup gets a little slower, though a single run can't cleanly separate that from noise or GC.&lt;/p&gt;

&lt;p&gt;This is also the shakiest measurement in the post, and worth knowing that up front. Rerun it and the exponent bounces around 0.2 to 0.3, and on a busy machine the fitter can even misname the family as &lt;code&gt;exponential&lt;/code&gt;, because a flat line has no real trend for it to lock onto, so noise wins. That instability is itself the fingerprint of constant time. Hold the thought, because it is our first hint that the clock measures more than complexity. We will come back to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  O(log n): binary search halves the problem each step
&lt;/h3&gt;

&lt;p&gt;Logarithmic growth is the one people struggle to picture. It means each step throws away half of what is left, so doubling the input adds just one more step. Binary search on a sorted Array is the textbook case, and Ruby has it built in as &lt;code&gt;Array#bsearch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt;

&lt;span class="no"&gt;SEARCHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200_000&lt;/span&gt;
&lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;array&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;
    &lt;span class="n"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SEARCHES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&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="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targets&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;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targets&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="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bsearch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# ~log2(n) comparisons each&lt;/span&gt;
&lt;span class="k"&gt;end&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;best fit: power  exponent b=0.12  R2=0.9906
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An exponent of 0.12: nearly flat, and notice it came in &lt;em&gt;below&lt;/em&gt; the O(1) run's 0.26, not above. That is not a mistake, it is the lesson arriving early. Logarithmic growth is so slow that on a ruler measuring polynomial degree it sits right down on the floor with constant time, and here noise in the O(1) run even pushed it higher than the log one. Going from a thousand to a million elements, a 1000× jump, only about tripled the depth of each search. The takeaway to bank: O(1) and O(log n) both read as "small exponent, nearly flat," and timings alone cannot reliably tell them apart or even order them. We will make that a rule later.&lt;/p&gt;

&lt;h3&gt;
  
  
  O(n): the linear scan from the opening
&lt;/h3&gt;

&lt;p&gt;Now the line that started this post. &lt;code&gt;Array#include?&lt;/code&gt; walks the array element by element until it finds a match. Search for something that isn't there and it has to touch all &lt;code&gt;n&lt;/code&gt; elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt;

&lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&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="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;array&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="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# absent value, so the scan can't stop early&lt;/span&gt;
&lt;span class="k"&gt;end&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;best fit: linear  exponent b=0.96  R2=1.0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exponent 0.96, an R² of 1.0, and this time the gem named the family &lt;code&gt;linear&lt;/code&gt; outright. Double the array and the scan takes twice as long. This is the shape hiding inside that &lt;code&gt;select&lt;/code&gt; from the opening, and in a moment we will see what happens when you nest it.&lt;/p&gt;

&lt;h3&gt;
  
  
  O(n log n): sort, and a first look at trouble
&lt;/h3&gt;

&lt;p&gt;Sorting is the workhorse of the "slightly worse than linear" class. Ruby's &lt;code&gt;Array#sort&lt;/code&gt; is a quicksort variant, average O(n log n):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt;

&lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&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="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shuffle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# fresh shuffle so each sort starts unsorted&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;array&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="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;
&lt;span class="k"&gt;end&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;best fit: power  exponent b=1.09  R2=0.9998
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look closely, because this is the crux of the whole post. The exponent is 1.09. Not 2. Barely above the linear scan's 0.96. Sorting is genuinely O(n log n), meaningfully more work than a linear pass, and the measurement can barely tell the difference. The log factor is nearly invisible to a power fit. That is not a flaw in this gem; it is a limit of the technique itself, and it has a whole section coming. For now, just notice that O(n) and O(n log n) landed almost on top of each other: 0.96 and 1.09.&lt;/p&gt;

&lt;h3&gt;
  
  
  O(n²): the accidental one
&lt;/h3&gt;

&lt;p&gt;Here is the opening bug, built on purpose. Filter &lt;code&gt;n&lt;/code&gt; rows, and for each row do an &lt;code&gt;include?&lt;/code&gt; scan over an &lt;code&gt;n&lt;/code&gt;-element Array. That is &lt;code&gt;n&lt;/code&gt; rows times an &lt;code&gt;n&lt;/code&gt;-element scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt;

&lt;span class="n"&gt;sizes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# smaller: n² gets big fast&lt;/span&gt;

&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt; &lt;span class="c1"&gt;# values that are absent, forcing a full scan&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&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;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&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="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&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;best fit: power  exponent b=1.99  R2=1.0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exponent 1.99, near enough to 2. That is quadratic, plain as day, and it is the number that separates a page that loads instantly from one that times out. Swap that Array for a Set and the exponent drops back toward 1, because a Set is Hash-backed and its &lt;code&gt;include?&lt;/code&gt; is average O(1). That is why a Set is the right structure for a membership check, and we will gate exactly this difference in CI later.&lt;/p&gt;

&lt;h3&gt;
  
  
  O(2ⁿ): the one that ends the party
&lt;/h3&gt;

&lt;p&gt;Exponential growth is the cliff. Each &lt;code&gt;+1&lt;/code&gt; to the input roughly doubles the work. The classic offender is naive recursive Fibonacci, where every call spawns two more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require_relative&lt;/span&gt; &lt;span class="s2"&gt;"measure"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fib&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&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;fib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Exponential workloads need small, closely-spaced sizes, not a wide sweep.&lt;/span&gt;
&lt;span class="c1"&gt;# infer_trend accepts any array of sizes, not just Trend.range output.&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="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;step&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="nf"&gt;to_a&lt;/span&gt;

&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&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="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;do&lt;/span&gt; &lt;span class="o"&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;_i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;fib&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;end&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;best fit: exponential  (compounding growth)  R2=0.9999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gem names it &lt;code&gt;exponential&lt;/code&gt; and I skip the power exponent here, because for a compounding curve the polynomial ruler is meaningless. This is why &lt;code&gt;fib(50)&lt;/code&gt; would outlast your patience while &lt;code&gt;fib(30)&lt;/code&gt; returns instantly. The sizes only go to 32 for a reason.&lt;/p&gt;

&lt;p&gt;Here is the whole ladder from one run, so you can see the ruler climb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;O(1)  Hash#[]          best fit: linear  exponent b=0.26  R2=0.9976
O(log n)  bsearch      best fit: power  exponent b=0.12  R2=0.9906
O(n)  include?         best fit: linear  exponent b=0.96  R2=1.0000
O(n log n)  sort       best fit: power  exponent b=1.09  R2=0.9998
O(n^2)  select+incl    best fit: power  exponent b=1.99  R2=1.0000
O(2^n)  fib            best fit: exponential  (compounding growth)  R2=0.9999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your exact numbers will differ, because this is a stopwatch on a real machine. Even the order at the flat end can wobble, as O(1) and O(log n) just did. What holds is the climb once there is real growth to see: near-flat, then a linear ramp near 1, then a quadratic wall near 2, then off the exponential cliff. Here is the same story as curves:&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%2F6c8m5gbcwnre6t3gajpd.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%2F6c8m5gbcwnre6t3gajpd.png" alt="Growth curves for the six complexity classes, from the flat O(1) line to the near-vertical O(2ⁿ)" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-call version, and its rough edges
&lt;/h2&gt;

&lt;p&gt;You do not need the helper. The core of &lt;code&gt;benchmark-trend&lt;/code&gt; is a single call, and it is worth seeing raw:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"benchmark/trend"&lt;/span&gt;

&lt;span class="n"&gt;sizes&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;arrays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sizes&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="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;infer_trend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;repeat: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;arrays&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# absent value, so the scan is O(n)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"best fit: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"residual (R2): &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_sym&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="ss"&gt;:residual&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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;best fit: linear
residual (R2): 0.9999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;infer_trend&lt;/code&gt; returns the winning family as a Symbol and a Hash of every fit it tried. That is genuinely close to the &lt;code&gt;BigO.measure { }&lt;/code&gt; I went looking for. But it has three edges worth knowing before you trust its output, and none of them are in the README's happy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fits four curve families, and quadratic isn't one of them.&lt;/strong&gt; The candidates are exponential, power, linear, and logarithmic. There is no dedicated &lt;code&gt;n²&lt;/code&gt; model and no &lt;code&gt;n log n&lt;/code&gt; model. This is why we read polynomial degree off the &lt;em&gt;power&lt;/em&gt; fit's exponent instead of expecting a name like "quadratic." An exponent near 2 is how quadratic shows up. If you were hoping the tool would just print "O(n²)," it can't, and now you know why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The residual is R², and higher is better.&lt;/strong&gt; In the output above, &lt;code&gt;residual: 0.9999&lt;/code&gt; means the linear curve explained almost all the variance in the timings. Values run from 0 (useless) to 1 (perfect). Treat anything under about 0.9 as "the fit didn't take, don't trust the label." Strictly, the gem picks the winner on R² &lt;em&gt;and&lt;/em&gt; a small penalty for model complexity, so a fancier curve has to earn its win rather than edge ahead on a rounding error, but R² is the number you read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The human-readable trend string has a bug.&lt;/strong&gt; Every fit carries a pretty-printed formula, and for a power fit it prints its two numbers in the wrong slots. Here is a full run against &lt;code&gt;sort&lt;/code&gt;, which fits a power law, printing the formula next to the real values pulled from the Hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"benchmark/trend"&lt;/span&gt;

&lt;span class="n"&gt;sizes&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;arrays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sizes&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="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shuffle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;_trend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;infer_trend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;repeat: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;arrays&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:power&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"printed string:   &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:trend&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# what the gem shows you&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"slope (exponent): &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:slope&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;# the REAL growth exponent&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"intercept (coef): &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:intercept&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# the REAL coefficient&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;printed string:   1.09 * x^0.00
slope (exponent): 1.086
intercept (coef): 3.55684051103109e-08
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string reads &lt;code&gt;1.09 * x^0.00&lt;/code&gt;, which looks like an exponent of zero, a flat line. But the real exponent is 1.086, sitting in the &lt;code&gt;slope&lt;/code&gt; field, and the tiny coefficient is in &lt;code&gt;intercept&lt;/code&gt;. The formatter swapped them. The gem's own README shows the same swapped shape in its example output, so this is not a fluke on my machine. The lesson is simple: read &lt;code&gt;:slope&lt;/code&gt; and &lt;code&gt;:intercept&lt;/code&gt; from the Hash, and ignore the pretty string.&lt;/p&gt;

&lt;p&gt;Those edges aside, the fitted curve buys you something useful: it can project a runtime at a size you never ran. &lt;code&gt;Benchmark::Trend.fit_at&lt;/code&gt; takes the slope and intercept from a fit and evaluates the curve at any &lt;code&gt;n&lt;/code&gt;. Feed it the O(n²) filter's power fit and ask what a hundred thousand rows would cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;power&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:power&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# from the O(n²) measurement above&lt;/span&gt;
&lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:power&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;slope: &lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:slope&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                     &lt;span class="ss"&gt;intercept: &lt;/span&gt;&lt;span class="n"&gt;power&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:intercept&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;n: &lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"projected time at n=100_000: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&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="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; s"&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; projected time at n=100_000: 31.3 s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The absolute seconds are machine-specific and will swing between runs, so trust the order of magnitude, not the decimal. That is the whole reason complexity is worth knowing: an exponent near 2 means roughly ten times the rows costs a hundred times the time, and now you can see the wall before you hit it in production.&lt;/p&gt;

&lt;p&gt;One more thing to know up front, not a bug but a fact. &lt;code&gt;benchmark-trend&lt;/code&gt; last shipped version 0.4.0 in March 2020, and its RSpec companion &lt;code&gt;rspec-benchmark&lt;/code&gt; shipped 0.6.0 two days later. Neither has had a release since. They still install and run cleanly on Ruby 4.0, which is what every example here runs on, but you are adopting stable, dormant code, and that is worth a conscious choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the stopwatch lies
&lt;/h2&gt;

&lt;p&gt;Everything so far makes measurement look easy. It isn't, and the gap between "I measured it" and "I know its complexity" is where people get burned. Four ways the clock misleads you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It measures Big Theta, not Big O.&lt;/strong&gt; This is the subtle one. Big O is a worst-case upper bound, a promise about the worst input the algorithm could ever face. A stopwatch can't measure a promise. It measures what actually happened on the inputs &lt;em&gt;you&lt;/em&gt; chose. Feed quicksort already-sorted data and you might see one shape; feed it its pathological worst case and you'll see another. What you get back is the observed order of growth on your workload, which theorists call Big Theta, not the worst-case Big O.&lt;/p&gt;

&lt;p&gt;The Python equivalent of this technique, the &lt;a href="https://github.com/pberkes/big_O" rel="noopener noreferrer"&gt;&lt;code&gt;big_O&lt;/code&gt;&lt;/a&gt; package, says so flatly in its own docs: "Strictly speaking, we're empirically computing the Big Theta class." Every demo above quietly chose its inputs. The &lt;code&gt;include?&lt;/code&gt; scan searched for an &lt;em&gt;absent&lt;/em&gt; value on purpose, to force the full-length worst case. Change that decision and you change the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is blind to log factors.&lt;/strong&gt; Remember sort landing at 1.09, right next to the linear scan's 0.96? That is not bad luck, it is arithmetic. When you double the input, an O(n) algorithm's time doubles and an O(n log n) algorithm's time slightly-more-than-doubles, and "slightly more" is nearly invisible against measurement noise. Princeton's own course notes put it bluntly: you "cannot identify logarithmic factors with the doubling hypothesis." So a power fit cannot cleanly separate O(n) from O(n log n). If your exponent lands around 1, you have learned it is roughly linear, and to tell linear from linearithmic you have to go read the code. The clock won't do it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A too-narrow range invents an answer.&lt;/strong&gt; Watch the same linear scan, measured over a tiny range of sizes and then a wide one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;op&lt;/span&gt;    &lt;span class="o"&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="n"&gt;array&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# same O(n) scan both times&lt;/span&gt;
&lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="o"&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="n"&gt;n&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"narrow range (100..800):"&lt;/span&gt;
&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="n"&gt;build&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;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"wide range (1_000..1_000_000):"&lt;/span&gt;
&lt;span class="n"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&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="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;prepare: &lt;/span&gt;&lt;span class="n"&gt;build&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;op&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;narrow range (100..800):
best fit: constant  exponent b=0.60  R2=1.0000
wide range (1_000..1_000_000):
best fit: linear  exponent b=0.96  R2=1.0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Over 100 to 800 elements, a linear scan looks &lt;em&gt;constant&lt;/em&gt;, with a perfect R² of 1.0 on the wrong answer. (&lt;code&gt;constant&lt;/code&gt; here is not a fifth family; it is what the gem calls a linear fit whose slope came out flat at zero, and &lt;code&gt;perform_constant&lt;/code&gt; later is that same zero-slope special case.) At those sizes the scan is so fast that fixed overhead drowns the actual growth. The high R² gives you false confidence in a false label. Only across a wide range, where the real work dominates, does the linear shape appear. The rule the research literature agrees on: your smallest size must be big enough that the operation itself, not the overhead around it, dominates the clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The machine leaks into the measurement.&lt;/strong&gt; Garbage collection pauses, CPU cache behavior, other processes, JIT warmup: all of it lands in the timings. The usual teaching model says these effects only shift the constant factor and leave the exponent alone. Mostly true, but not a guarantee. Our own O(1) Hash lookup came back at exponent 0.26 instead of 0, most likely because its memory working set grew past the CPU cache. In a garbage-collected runtime, effects that scale with &lt;code&gt;n&lt;/code&gt; can bend the exponent itself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;benchmark-trend&lt;/code&gt; fights back where it can: it uses a monotonic clock, runs &lt;code&gt;GC.start&lt;/code&gt; before each size, and averages repeated runs when you pass &lt;code&gt;repeat:&lt;/code&gt;. It does not warm up the code, though, and wall-clock timing in Ruby is noisier than the deterministic operation-counting the academic papers use. Measure the same thing twice and you will get two slightly different exponents. That is normal.&lt;/p&gt;

&lt;p&gt;None of this makes the technique useless. It makes it a tool with a manual. You measure to build intuition and to catch regressions, and when two adjacent classes are ambiguous, you fall back to reading the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gating complexity in CI
&lt;/h2&gt;

&lt;p&gt;Measuring by hand teaches you the shapes. The payoff at work is different: catch the day someone turns your O(n) into an O(n²) without noticing. That is what &lt;a href="https://github.com/piotrmurach/rspec-benchmark" rel="noopener noreferrer"&gt;&lt;code&gt;rspec-benchmark&lt;/code&gt;&lt;/a&gt; is for. It wraps &lt;code&gt;benchmark-trend&lt;/code&gt; in RSpec matchers, so you assert a growth class the way you assert anything else.&lt;/p&gt;

&lt;p&gt;The matchers are &lt;code&gt;perform_constant&lt;/code&gt;, &lt;code&gt;perform_logarithmic&lt;/code&gt;, &lt;code&gt;perform_linear&lt;/code&gt;, &lt;code&gt;perform_power&lt;/code&gt;, and &lt;code&gt;perform_exponential&lt;/code&gt;. There is no &lt;code&gt;perform_quadratic&lt;/code&gt;, for the four-families reason from earlier; a quadratic is a &lt;code&gt;perform_power&lt;/code&gt; whose exponent lands near 2. Here is a gate on the membership filter from the opening, pinned to a Set so it stays linear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"benchmark/trend"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"rspec-benchmark"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"set"&lt;/span&gt;

&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include&lt;/span&gt; &lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Matchers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="no"&gt;SIZES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Benchmark&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3_200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;ratio: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;ROWS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&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;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;SEEN_SET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&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;n&lt;/span&gt;&lt;span class="p"&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_set&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"membership filtering"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"stays linear with a Set"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&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;_i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;ROWS&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="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;SEEN_SET&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="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;perform_linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;in_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3_200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ratio&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="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details carry the weight. The block takes &lt;code&gt;n&lt;/code&gt;, so the work has to scale with &lt;code&gt;n&lt;/code&gt;: we key the pre-built inputs by size and look them up, exactly like the helper did, so we never time the setup. And &lt;code&gt;in_range(min, max).ratio(r)&lt;/code&gt; rebuilds the same geometric ladder &lt;code&gt;Benchmark::Trend.range&lt;/code&gt; would, which is why keying the data by &lt;code&gt;n&lt;/code&gt; lines up. With &lt;code&gt;SEEN_SET&lt;/code&gt; a Set, &lt;code&gt;include?&lt;/code&gt; is average O(1), so filtering &lt;code&gt;n&lt;/code&gt; rows costs O(n) total and the gate passes green.&lt;/p&gt;

&lt;p&gt;Now regress it. Swap that Set back to a plain Array and keep the linear gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;SEEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SIZES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&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;n&lt;/span&gt;&lt;span class="p"&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# Array, not Set&lt;/span&gt;

&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"is supposed to stay linear (but doesn't)"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&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;_i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;ROWS&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="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;SEEN&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="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;perform_linear&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;in_range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3_200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ratio&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="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same filter is now O(n²), the gate goes red, and it names the class it actually found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failures:
  1) membership filtering is supposed to stay linear (but doesn't)
     Failure/Error: expect { |n, _i| ROWS[n].select { |r| SEEN[n].include?(r) } }
       expected block to perform linear, but performed power
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That "expected linear, but performed power" is the regression caught before production. One honest warning, straight from the gem's own README: these matchers are "potentially flaky." They are timing-based, so a loaded CI machine can occasionally flip a borderline result. Keep them out of your normal suite. Tag them, give them their own run, and lean on them for the classes that are unambiguous, an O(n) that must not become O(n²).&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for it, and when not
&lt;/h2&gt;

&lt;p&gt;Empirical measurement is a teacher and a smoke alarm, not a proof. Reach for it to build a feel for a method you don't know: put &lt;code&gt;Array#include?&lt;/code&gt;, &lt;code&gt;Set#include?&lt;/code&gt;, and &lt;code&gt;Hash#[]&lt;/code&gt; on the clock and the difference stops being abstract. Ruby's docs rarely state the complexity of built-in methods (&lt;code&gt;bsearch&lt;/code&gt; is a rare one that spells out O(log n); &lt;code&gt;include?&lt;/code&gt; and &lt;code&gt;sort&lt;/code&gt; say nothing), so measuring is often how you find out. And reach for it to guard a hot path in CI, where a &lt;code&gt;perform_linear&lt;/code&gt; gate on the endpoint that melted last quarter is cheap insurance.&lt;/p&gt;

&lt;p&gt;Keep in mind what it does &lt;em&gt;not&lt;/em&gt; measure: the constant factor. &lt;code&gt;benchmark-trend&lt;/code&gt; tells you the shape of the curve, not which of two methods is faster at the size you actually run. That second question, "is a Set worth it for my 50 items," belongs to &lt;code&gt;benchmark-ips&lt;/code&gt; or the stdlib &lt;code&gt;Benchmark&lt;/code&gt;. The two are complementary, one for the growth, one for the wall-clock at a fixed size.&lt;/p&gt;

&lt;p&gt;Then the boundaries. Don't reach for it to prove a worst-case bound; it measures the case you fed it. Don't trust it to split two adjacent classes; when the exponent sits near 1, the clock has told you everything it can, and the code has the rest.&lt;/p&gt;

&lt;p&gt;Don't point it at IO, either. Every demo here is pure in-memory CPU work, which is where wall-clock inference behaves. A loop of database queries or network calls has its growth swamped by connection and IO variance, so the exponent turns to mush. For the classic N+1 query, count the queries, don't time the exponent.&lt;/p&gt;

&lt;p&gt;And remember a worse Big O can still win at the sizes you actually run. For small &lt;code&gt;n&lt;/code&gt;, constants and memory layout dominate, which is why hybrid sort implementations fall back to insertion sort on small chunks even though it is O(n²). Big O is the shape of the curve as &lt;code&gt;n&lt;/code&gt; grows, not the winner at any single size. When it matters, measure the crossover for your own case.&lt;/p&gt;

&lt;p&gt;So measure your code. Watch the exponent climb from a flat line to a linear ramp to a quadratic wall. Just keep one hand on the manual, because the clock will tell you a clean story, and sometimes the story is wrong.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>performance</category>
      <category>algorithms</category>
      <category>testing</category>
    </item>
    <item>
      <title>Keeping code alive on a DOM you don't own: 12-year Chrome extension story</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Fri, 17 Jul 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/svyatov/keeping-code-alive-on-a-dom-you-dont-own-12-year-chrome-extension-story-2g3o</link>
      <guid>https://dev.to/svyatov/keeping-code-alive-on-a-dom-you-dont-own-12-year-chrome-extension-story-2g3o</guid>
      <description>&lt;p&gt;In April 2014 I wanted to sort posts on the Hacker News front page by points, by time, or by comments. Nothing I found did it without cluttering the page, so I wrote a tiny Chrome extension over a couple of days, uploaded it to the Web Store, and mostly stopped thinking about it for ten years.&lt;/p&gt;

&lt;p&gt;I touched it exactly once in that decade, in 2019, to fix the selectors.&lt;/p&gt;

&lt;p&gt;Then in March 2024 I rewrote it from scratch, with the old version still working fine: I wanted TypeScript, React, and tests, and Manifest v3 while I was in there. That's when I started actually maintaining it.&lt;/p&gt;

&lt;p&gt;It's called &lt;a href="https://chromewebstore.google.com/detail/hacker-news-sorted/djkcnbncofmjekhlhemlkinfpkamlkaj" rel="noopener noreferrer"&gt;Hacker News Sorted&lt;/a&gt;, it's now at v2.6.1, and it has one dependency that never appears in package.json: the exact HTML structure of the Hacker News front page. HN can change that structure any day, without a changelog, and they owe me nothing.&lt;/p&gt;

&lt;p&gt;Sorting 30 rows of a table is a weekend project. This post is about everything else: the selector that survived a layout change and the one that didn't, the badge that tells users it's my fault, the GitHub Action that checks HN's HTML every morning, and the timestamps HN quietly rewrites.&lt;/p&gt;

&lt;p&gt;The obvious question first: HN has an official API, so why scrape the DOM at all? Because the extension doesn't need data, it needs to rearrange the page you're already looking at. Every number it sorts by is already sitting in the rows, true timestamps included.&lt;/p&gt;

&lt;p&gt;Fetching the same 30 items over the network would only add latency, a wider permission surface, and a privacy story to explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The selector war
&lt;/h2&gt;

&lt;p&gt;HN's front page is one big &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt;. Each story is three &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt;s, emitted in order: a title row, an info row (points, age, comments), and an empty spacer row. The natural way to grab them is &lt;code&gt;nth-child&lt;/code&gt; arithmetic, and that part hasn't needed a single change since the 2024 rewrite:&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="c1"&gt;// app/constants.ts (v2.6.1)&lt;/span&gt;
&lt;span class="nx"&gt;TITLE_ROWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tr:nth-child(3n+1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;INFO_ROWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tr:nth-child(3n+2)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;SPACER_ROWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tr:nth-child(3n+3)&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;Each &lt;code&gt;3n+k&lt;/code&gt; picks every third row from offset k: titles at 1, 4, 7, info rows at 2, 5, 8, spacers at 3, 6, 9. The pattern assumes nothing non-story slips between the rows. HN's one exception, the "More" link at the bottom, sits after all 30 stories, so it never shifts the count above it.&lt;/p&gt;

&lt;p&gt;The trap is what those selectors run against. Until March 2026, my two outer anchors were absolute paths through HN's table soup:&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="c1"&gt;// app/constants.ts, the anchors before March 2026 (v2.3.1)&lt;/span&gt;
&lt;span class="nx"&gt;CONTROL_PANEL_PARENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body &amp;gt; center &amp;gt; table &amp;gt; tbody &amp;gt; tr:nth-child(2) &amp;gt; td &amp;gt; table &amp;gt; tbody &amp;gt; tr &amp;gt; td:nth-child(3)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;TABLE_BODY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;body &amp;gt; center &amp;gt; table &amp;gt; tbody &amp;gt; tr:nth-child(4) &amp;gt; td &amp;gt; table &amp;gt; tbody&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;Every &lt;code&gt;nth-child(N)&lt;/code&gt; in there is a bet that HN will never insert a row above row N. On March 11, 2026, HN added a logo row to its outer table and won the bet. Both anchors stopped matching, and the extension didn't crash or throw. It just silently did nothing, on every user's machine at once.&lt;/p&gt;

&lt;p&gt;I committed a hotfix at 17:54 that day: bump the shifted indices by one. Committed, not delivered: an extension fix reaches users only after the Chrome Web Store review approves the update, so every hour of my reaction time has Google's review time stacked on top of it.&lt;/p&gt;

&lt;p&gt;Then I sat with the diff and realized I was patching the symptom. The real problem was anchoring to &lt;em&gt;positions&lt;/em&gt; when HN's markup is full of &lt;em&gt;names&lt;/em&gt; that have barely moved in two decades (&lt;code&gt;.pagetop&lt;/code&gt; is in the earliest Wayback Machine capture of HN, from 2007). At 18:47, 53 minutes after the hotfix, I replaced the positional paths with semantic anchors.&lt;/p&gt;

&lt;p&gt;My first pass at that was &lt;code&gt;#hnmain tr:has(.hnname) &amp;gt; td:last-child&lt;/code&gt;. (&lt;code&gt;:has()&lt;/code&gt; matches an element by what it contains, the one relational selector CSS gives you.) It felt clever and it was wrong: &lt;code&gt;tr:has(.hnname)&lt;/code&gt; matches any row with &lt;code&gt;.hnname&lt;/code&gt; anywhere inside it, and HN nests tables inside tables, so an outer wrapper row matched along with the nav row it contains.&lt;/p&gt;

&lt;p&gt;The sort menu injected itself above the page header. The version that stuck, two days later, pins every step with direct-child combinators:&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="c1"&gt;// app/constants.ts (v2.6.1)&lt;/span&gt;
&lt;span class="nx"&gt;CONTROL_PANEL_PARENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#hnmain tr:has(&amp;gt; td &amp;gt; .pagetop &amp;gt; .hnname) &amp;gt; td:last-child&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;TABLE_BODY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#hnmain #bigbox &amp;gt; td &amp;gt; table &amp;gt; tbody&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;Read the first one aloud: the cell next to the nav that contains the site name. It no longer cares how many wrapper tables HN nests around that row, and it can't accidentally match a parent, because every hop is &lt;code&gt;&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I want to be honest about what this buys. These are still bets. &lt;code&gt;td:last-child&lt;/code&gt; is positional; the &lt;code&gt;tbody&lt;/code&gt; chain still encodes nesting. The difference is what the bets are on: names HN's own stylesheet depends on, plus a few explicit local hops, instead of a count of rows in a table I can't see change.&lt;/p&gt;

&lt;p&gt;HN renaming &lt;code&gt;#hnmain&lt;/code&gt; would break me; HN renaming &lt;code&gt;#hnmain&lt;/code&gt; would also break HN's own CSS, so we'd at least go down together. And the new anchors have survived exactly zero HN layout changes so far, because HN hasn't shipped one since. The positional paths survived two years before losing. Ask me in 2028.&lt;/p&gt;

&lt;p&gt;One boundary on all of this: it works because HN's markup is unusually stable and hand-named. On a bundler-built SPA with hashed class names there are no names worth anchoring to, and you're down to ARIA roles, text content, and luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the selectors fail anyway
&lt;/h2&gt;

&lt;p&gt;So v2.4.0 added the assumption that the new anchors will eventually lose too. The failure mode that scares me isn't a selector that throws; it's one that matches nothing while I have no way to know.&lt;/p&gt;

&lt;p&gt;The content script waits for HN's DOM with a &lt;code&gt;MutationObserver&lt;/code&gt; on &lt;code&gt;document.body&lt;/code&gt; (the browser API that calls you back whenever the DOM changes), then runs a sanity check before injecting anything:&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="c1"&gt;// content.tsx (v2.6.1)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifyAndInject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;getTableBody&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setLayoutStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setLayoutStatus&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="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;injectRootElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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 content script hands &lt;a href="https://www.plasmo.com/" rel="noopener noreferrer"&gt;Plasmo&lt;/a&gt; (the extension framework) a Promise that will deliver the element to render into; the &lt;code&gt;resolve&lt;/code&gt; here is that Promise's resolve.&lt;/p&gt;

&lt;p&gt;If the panel's parent shows up but the story table doesn't, the layout has shifted in a way I can't handle, and &lt;code&gt;setLayoutStatus(false)&lt;/code&gt; writes a flag to &lt;code&gt;chrome.storage.sync&lt;/code&gt;. A 3-second timeout covers the other case: if the parent never appears at all, same flag, and the panel never renders.&lt;/p&gt;

&lt;p&gt;The badge itself can't be painted from the content script: only the extension's background service worker may call &lt;code&gt;chrome.action&lt;/code&gt;, and &lt;code&gt;chrome.storage&lt;/code&gt; is the bridge between those two worlds. Here's the entire background file, 19 lines:&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="c1"&gt;// background.ts (v2.6.1)&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;SETTINGS_KEYS&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;~app/constants&lt;/span&gt;&lt;span class="dl"&gt;'&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;updateBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBadgeText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ok&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBadgeBackgroundColor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#E05050&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBadgeTextColor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#FFFFFF&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="c1"&gt;// Restore badge state on service worker restart&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SETTINGS_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LAYOUT_OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SETTINGS_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LAYOUT_OK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;updateBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// React to layout status changes&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onChanged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;changes&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SETTINGS_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LAYOUT_OK&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;updateBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;SETTINGS_KEYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LAYOUT_OK&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;newValue&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;Users see a red &lt;code&gt;:(&lt;/code&gt; on the extension icon, and the popup shows a banner: "Sorting temporarily unavailable :( Hacker News appears to have changed its page layout. We're aware, and a fix is on the way." A broken extension that tells you it's broken gets a bug report. A silent one gets an uninstall.&lt;/p&gt;

&lt;p&gt;The two-part structure of that file is Manifest v3 discipline. An MV3 service worker is killed after about 30 seconds of idle, and every global variable dies with it, so the flag's only real home is &lt;code&gt;chrome.storage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The listener at the bottom reacts to changes while the worker happens to be alive (anything but an explicit &lt;code&gt;false&lt;/code&gt; counts as healthy). The &lt;code&gt;get&lt;/code&gt; at the top runs every time Chrome starts the worker fresh, and it's what puts the badge back after the cases where Chrome resets badge state entirely, like a browser restart or an extension reload.&lt;/p&gt;

&lt;p&gt;Write state assuming your process is already dead; read it back assuming you just woke up with amnesia.&lt;/p&gt;

&lt;p&gt;Scope, for the record: the script matches every &lt;code&gt;news.ycombinator.com&lt;/code&gt; page except item pages, which get a separate content script for the comment features.&lt;/p&gt;

&lt;p&gt;One wart: at v2.6.1 the check has two false positives. Pages with no story list at all (your profile, the submit form) wait 3 seconds, find no table, and flip the flag.&lt;/p&gt;

&lt;p&gt;And on a slow-loading page the timeout could flip the flag even after the menu mounted fine, because nothing cancelled it. Both fixes shipped right after this release. Detection code needs the same skepticism as the code it watches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GitHub Action that reads HN every morning
&lt;/h2&gt;

&lt;p&gt;The badge tells &lt;em&gt;users&lt;/em&gt; something broke. It doesn't tell &lt;em&gt;me&lt;/em&gt;, unless I happen to open HN that day. So the repo has a canary, &lt;code&gt;.github/workflows/monitor.yml&lt;/code&gt;, and this is the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HN Layout Monitor&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check-layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oven-sh/setup-bun@v2&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun install --frozen-lockfile&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run fixture:update&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bun run test:integration&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every morning at 09:00 UTC it downloads the live HN homepage, overwrites the HTML fixture my integration tests run against, and runs the suite. If HN changed something my selectors care about, the suite fails, and GitHub's stock failed-workflow email lands in my inbox. No custom alerting, no dashboard. Nineteen lines of YAML watching a website on my behalf.&lt;/p&gt;

&lt;p&gt;The integration tests assert more than "selectors match something." They pin the row counts, the elements inside each row, and even the formats of what's in them:&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="c1"&gt;// app/utils/selectors.integration.test.ts (v2.6.1)&lt;/span&gt;
&lt;span class="c1"&gt;// Canary: validate HN's .age title attribute format ("ISO_DATETIME UNIX_TIMESTAMP")&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;titleFormatRegex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;T&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;infoRows&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="nx"&gt;row&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;ageEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTimeElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ageEl&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ageEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;age title should match "ISO UNIX" format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;titleFormatRegex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Canary: validate HN's .age text format ("X minute(s)/hour(s)/day(s) ago")&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ageTextRegex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+ &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;minute|hour|day&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;s&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt; ago$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;infoRows&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="nx"&gt;row&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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTimeElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;age text should match "N unit(s) ago"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ageTextRegex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If HN drops the Unix timestamp from the &lt;code&gt;title&lt;/code&gt; attribute, time sort breaks and the first regex catches it. If "7 hours ago" ever becomes "7h", the second one fails before I've written a parser that cares.&lt;/p&gt;

&lt;p&gt;A daily cron doesn't buy "before the first user notices." If HN ships a change at noon, users hit it all afternoon and my email arrives the next morning.&lt;/p&gt;

&lt;p&gt;What the canary really buys is a bound: I find out in at most a day, instead of whenever I next happen to open HN with my own extension working. For a one-person project, turning "unbounded" into "24 hours" is most of the value.&lt;/p&gt;

&lt;p&gt;And then the canary itself broke. In July 2026 the monitor started failing every day, and the cause wasn't HN's layout. The updater also refreshes a comment-page fixture (the comment features later in this post are tested the same way), and that fixture pinned a hardcoded item id.&lt;/p&gt;

&lt;p&gt;HN had removed that thread, and their server answered every request for it with 429, forever. The watchdog needed watching.&lt;/p&gt;

&lt;p&gt;The fix was to stop pinning data I could derive: the updater now picks the most-commented story off the fresh homepage each run, sends a browser User-Agent, and backs off politely when HN rate-limits. It's &lt;a href="https://github.com/svyatov/hacker_news_sorted/blob/main/app/__fixtures__/updateFixture.ts" rel="noopener noreferrer"&gt;75 lines&lt;/a&gt; if you want the fetch half of the pattern too. If your canary hardcodes anything about the site it watches, that's where it will rot.&lt;/p&gt;

&lt;p&gt;If you maintain anything that scrapes or decorates someone else's site, steal this pattern. A cron workflow, a fixture refresh, and your existing tests.&lt;/p&gt;

&lt;p&gt;One gotcha before you do: GitHub automatically disables scheduled workflows in a public repository after 60 days without repository activity. A canary guarding a project you consider finished is exactly the workflow that dies of that, so an occasional commit (or a calendar reminder to re-enable) is part of the deal.&lt;/p&gt;

&lt;p&gt;And a risk I haven't paid yet: the canary fetches HN from GitHub's shared runner IPs, exactly the kind of traffic a site might one day rate-limit wholesale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timestamps HN rewrites
&lt;/h2&gt;

&lt;p&gt;HN has a "second chance" pool: moderators and reviewers pick good stories that got no traction, and the site re-ups them onto the front page later. When that happens, HN rewrites the story's &lt;em&gt;displayed&lt;/em&gt; age.&lt;/p&gt;

&lt;p&gt;Not to the re-up moment, even: dang &lt;a href="https://news.ycombinator.com/item?id=19774614" rel="noopener noreferrer"&gt;has explained&lt;/a&gt; it's back-computed, the time the story would have needed to be posted to deserve its new rank. A story submitted four days ago can honestly say "1 hour ago" on the front page.&lt;/p&gt;

&lt;p&gt;It isn't rare, either, at least not on the day I checked. Here's a row from the snapshot of the front page I took while writing this (July 16, 2026):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"age"&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"2026-07-12T17:19:47 1783876787"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"item?id=48882777"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;1 hour ago&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;title&lt;/code&gt; attribute is the tell: HN rewrites the visible text, but the machine-readable timestamp keeps the original submission time, as an ISO datetime plus a Unix epoch. (Don't take the attribute's word for it: the official API's &lt;code&gt;time&lt;/code&gt; field for that item returns the same 1783876787.)&lt;/p&gt;

&lt;p&gt;You can count the rewrites on today's front page yourself. Paste this into DevTools on news.ycombinator.com:&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&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;HOURS_PER_UNIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hour&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="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&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;SLACK_HOURS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// beyond truncation, allow a little drift before calling it a rewrite&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;rewritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;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;age&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;span.age&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trueHours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;epoch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;minute|hour|day&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// "3 days ago" is truncated: it covers anything from 72 up to (but not including) 96 hours.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;labelCeiling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;HOURS_PER_UNIT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trueHours&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;labelCeiling&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SLACK_HOURS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="o"&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="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" but submitted &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;trueHours&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt; hours ago`&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rewritten&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;span.age&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ages look rewritten`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against my snapshot it printed (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"1 hour ago" but submitted 88.4 hours ago
"9 hours ago" but submitted 116.7 hours ago
"5 hours ago" but submitted 116.0 hours ago
13 of 30 ages look rewritten
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's one snapshot on one day, so your count will differ. And the snippet detects rewritten labels, not who rewrote them: the second-chance pool is the best-documented mechanism, but moderator re-ups generally leave the same signature.&lt;/p&gt;

&lt;p&gt;What survives is narrower: on any given day, a chunk of the HN front page may be days older than it says.&lt;/p&gt;

&lt;p&gt;So the extension sorts by the field HN doesn't rewrite. The parser is small:&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="c1"&gt;// app/utils/parsers.ts (v2.6.1)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TITLE_UNIX_TS_INDEX&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;infoRow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;timeElement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTimeElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;infoRow&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;timeElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&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;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timeElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&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;unixTs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nx"&gt;TITLE_UNIX_TS_INDEX&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;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unixTs&lt;/span&gt;&lt;span class="p"&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="nx"&gt;unixTs&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;Once real timestamps were in memory anyway, showing them was nearly free. Since v2.5.0 the extension replaces the misleading label with the true age ("1 hour ago" becomes "3 days ago"), stashing HN's original text in a &lt;code&gt;data-original-age&lt;/code&gt; attribute on the link.&lt;/p&gt;

&lt;p&gt;Toggle the feature off and it restores the attribute and deletes it. No destructive edits.&lt;/p&gt;

&lt;p&gt;Two confessions from the git history, since this section makes me sound more deliberate than I was.&lt;/p&gt;

&lt;p&gt;The 2024 rewrite parsed the &lt;code&gt;title&lt;/code&gt; attribute by throwing the whole thing at &lt;code&gt;new Date()&lt;/code&gt;. That worked while the attribute was a bare ISO date, and Wayback Machine snapshots show it stayed bare until fall 2024.&lt;/p&gt;

&lt;p&gt;Then HN changed the format twice in two months: fractional seconds and a &lt;code&gt;Z&lt;/code&gt; by October 1, today's "ISO space epoch" by November 1. V8 parses the first happily and returns &lt;code&gt;Invalid Date&lt;/code&gt; for the second.&lt;/p&gt;

&lt;p&gt;So from around November 2024 until the fix shipped in late December 2025, sorting by time was quietly broken for every user of the extension. Nobody reported it. Not one review, not one issue, and I didn't notice either, for over a year: I sort by points myself, so the broken path wasn't one I walked. That is what silent failure looks like.&lt;/p&gt;

&lt;p&gt;The second confession: an earlier version of this very post claimed the second-chance rewrite was corrupting my time sort before v2.5.0. It wasn't. The old code read the ISO half of the same attribute, which is just as original as the epoch half. The rewrite only ever lied to &lt;em&gt;readers&lt;/em&gt;, through the visible text.&lt;/p&gt;

&lt;p&gt;I caught my own false claim by re-checking it against the git history while writing. Verifying your war stories is part of maintenance too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorts HN doesn't have: velocity and heat
&lt;/h2&gt;

&lt;p&gt;Points, time, and comments are HN's own numbers, just sortable. Version 2.6.0 added two derived ones, and they exist because a different feature died.&lt;/p&gt;

&lt;p&gt;I wanted to pin a thread's highest-scored comments to the top, until I checked the DOM and the API and found that HN hides comment scores from everyone except each comment's author. There is nothing to sort by. That dead end turned into: what &lt;em&gt;can&lt;/em&gt; I compute from the numbers already on the page?&lt;/p&gt;

&lt;p&gt;Two things, it turns out. &lt;strong&gt;Velocity&lt;/strong&gt; is points per hour, damped:&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="c1"&gt;// app/utils/sorters.ts (v2.6.1)&lt;/span&gt;
&lt;span class="c1"&gt;// Borrow HN's +2 gravity constant to keep young posts from spiking (not its full power-law rank).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VELOCITY_DAMPING_HOURS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;velocity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ParsedRow&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Clamp age at 0 so a client clock running behind a post's server timestamp can't drive the&lt;/span&gt;
  &lt;span class="c1"&gt;// denominator to zero/negative (Infinity/NaN/negative rank); keeps the no-Infinity/NaN guarantee.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ageHours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nowInSeconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;SECONDS_PER_HOUR&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ageHours&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;VELOCITY_DAMPING_HOURS&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;Raw points-per-hour has a baby-post problem: a 20-minute-old story with a few upvotes beats everything on the page. Adding 2 hours to every denominator makes young posts earn their rank, and the penalty fades as posts age.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;+ 2&lt;/code&gt; isn't mine. In HN's own &lt;a href="https://github.com/wting/hackernews/blob/master/news.arc" rel="noopener noreferrer"&gt;ranking code&lt;/a&gt; the denominator is (age in hours + 2) raised to a gravity power. I borrow the constant, not the exponent: velocity decays linearly where HN's rank decays as a power law, so this ranks momentum, not HN's front page.&lt;/p&gt;

&lt;p&gt;And note which timestamp feeds it. &lt;code&gt;row.time&lt;/code&gt; comes from &lt;code&gt;getTime&lt;/code&gt; above, the epoch HN doesn't rewrite, so a second-chance story pretending to be an hour old doesn't get to pretend it's rising fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heat&lt;/strong&gt; is comments per point:&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="c1"&gt;// app/utils/sorters.ts (v2.6.1)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ParsedRow&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;HEAT_ZERO_POINTS_SENTINEL&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A high ratio means a thread draws more comments than upvotes, which is sometimes exactly what you came for. (I'm not the first to notice this signal: &lt;a href="https://github.com/paradite/hn-ratio" rel="noopener noreferrer"&gt;hn-ratio&lt;/a&gt; has ranked HN's top stories by comment-to-score ratio for years; its author reads a high ratio as "active and extended debates.")&lt;/p&gt;

&lt;p&gt;The sentinel is for job ads, which render with no score at all: &lt;code&gt;-1&lt;/code&gt; sinks them below every real story without the &lt;code&gt;Infinity&lt;/code&gt; and &lt;code&gt;NaN&lt;/code&gt; that naive division would spray into a comparator.&lt;/p&gt;

&lt;p&gt;Both sorts get a toggle in the popup, and turning off the sort you're currently using reverts the page to HN's default order rather than silently switching you to points. Substituting a different metric without asking felt like the kind of thing I'd file a bug about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being a guest on someone else's page
&lt;/h2&gt;

&lt;p&gt;Injection comes with etiquette problems on top of selector problems. The sort menu lives in HN's header, and HN's header has its own responsive behavior to not break.&lt;/p&gt;

&lt;p&gt;The menu has three tiers (full words, single letters, a native dropdown), and the breakpoints are count-aware: React publishes how many sorts are enabled onto the panel root as a &lt;code&gt;data-sort-count&lt;/code&gt; attribute, because the collapse thresholds are pure CSS and CSS can't read React state. The stylesheet keys its breakpoints on &lt;code&gt;[data-sort-count='6']&lt;/code&gt; and friends.&lt;/p&gt;

&lt;p&gt;Each threshold sits just above the width where the menu would push HN's own nav links onto a second line. I measured those widths against HN's actual header, per option count, by resizing a window like a caveman. It was worth it.&lt;/p&gt;

&lt;p&gt;The newest feature is on comment pages: the story author's comments get a subtle orange tint and an "OP" pill, and you can mark one other commenter per thread to highlight their replies. It exists because of a pattern HN regulars know well: someone else submits an article, and the actual author shows up in the comments saying "hi, I wrote this."&lt;/p&gt;

&lt;p&gt;The mark is stored in &lt;code&gt;sessionStorage&lt;/code&gt;, keyed by thread id. It survives reloads while you're reading and evaporates when the session ends, because a permanent cross-thread watchlist is a heavier thing than "follow this person through this discussion," and heavier wasn't the point.&lt;/p&gt;

&lt;p&gt;Keyboard shortcuts get the same etiquette. Each sort has a hotkey (P, T, C, V, H, and D to restore HN's order), and on the first press of a key the handler checks whether another extension already claimed it. If &lt;code&gt;event.defaultPrevented&lt;/code&gt; is true before I've done anything, something like Vimium got there first.&lt;/p&gt;

&lt;p&gt;All sort hotkeys go inert, and the menu shows a note naming the exact keys that are taken.&lt;/p&gt;

&lt;p&gt;Everything injected stays inside HN's palette (the orange is HN's own &lt;code&gt;#ff6600&lt;/code&gt;), including the dot that marks posts new since your last visit, which fades out over a configurable cooldown and holds still if your system asks for reduced motion.&lt;/p&gt;

&lt;p&gt;The settings popup follows your system's light or dark theme. The goal is furniture that looks like it was always there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack, size, and the React question
&lt;/h2&gt;

&lt;p&gt;TypeScript, React 19, and Plasmo, tested with Vitest against jsdom plus real-HTML fixtures, with Playwright generating the Web Store screenshots and demo GIFs. &lt;code&gt;bun&lt;/code&gt; runs everything. About 1,600 lines of TypeScript and 540 of CSS, tests excluded.&lt;/p&gt;

&lt;p&gt;No analytics, and the extension's own code makes no network calls (the manifest's only host permission is &lt;code&gt;news.ycombinator.com&lt;/code&gt;). Settings ride &lt;code&gt;chrome.storage.sync&lt;/code&gt;, which is Chrome's own sync infrastructure talking to Google's servers, not mine. MIT licensed.&lt;/p&gt;

&lt;p&gt;Does sorting 30 table rows need React? No. The sort itself is &lt;code&gt;querySelectorAll&lt;/code&gt;, a comparator, and re-appending rows in order.&lt;/p&gt;

&lt;p&gt;React pays rent in the popup and the injected menu: settings that sync live across devices, toggles that enable and disable sorts, conditional banners. The DOM work that touches HN's table is plain DOM code in plain functions, which is also what makes it testable against a fixture that is literally yesterday's HN homepage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twelve years, one lesson
&lt;/h2&gt;

&lt;p&gt;The extension has spent most of its life being finished. What kept it alive through the unfinished parts was never the feature code.&lt;/p&gt;

&lt;p&gt;It was the boring perimeter: selectors anchored to names instead of positions, a sanity check before injecting, a flag in storage instead of a global, a red badge, a cron job reading HN at breakfast. None of that is HN-specific. Any code that depends on HTML it doesn't control is in the same war, whether it admits it or not.&lt;/p&gt;

&lt;p&gt;Declare the dependency, even if only to yourself. Then build the perimeter around it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install: &lt;a href="https://chromewebstore.google.com/detail/hacker-news-sorted/djkcnbncofmjekhlhemlkinfpkamlkaj" rel="noopener noreferrer"&gt;Hacker News Sorted on the Chrome Web Store&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/svyatov/hacker_news_sorted" rel="noopener noreferrer"&gt;github.com/svyatov/hacker_news_sorted&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>javascript</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>The four places a hand-rolled Ruby facade gives out</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Thu, 16 Jul 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/svyatov/the-four-places-a-hand-rolled-ruby-facade-gives-out-3n77</link>
      <guid>https://dev.to/svyatov/the-four-places-a-hand-rolled-ruby-facade-gives-out-3n77</guid>
      <description>&lt;p&gt;You don't need a gem to build a facade. This is a perfectly good one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redis&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REDIS_POOL&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;App.config&lt;/code&gt; reads better than &lt;code&gt;Rails.configuration&lt;/code&gt; for the fortieth time, and &lt;code&gt;App.redis&lt;/code&gt; beats remembering which global holds the pool. These are real methods. &lt;code&gt;App.respond_to?(:redis)&lt;/code&gt; is true, the console tab-completes them, your editor jumps straight to the definition, and a wrong call raises &lt;code&gt;ArgumentError&lt;/code&gt; at the call site. Your test suite can stub them with a verifying double (a stub that refuses to fake a method the real object doesn't have). If your &lt;code&gt;App&lt;/code&gt; stays this size, close the tab. The class is the right tool, and reaching for a dependency to replace two lines would be silly.&lt;/p&gt;

&lt;p&gt;I want to be honest about that up front, because I wrote a gem called &lt;a href="https://github.com/svyatov/briefly" rel="noopener noreferrer"&gt;briefly&lt;/a&gt; that builds this same object, and the interesting question isn't "isn't a facade nice." It's &lt;em&gt;when does a hand-rolled one start to cost you&lt;/em&gt;, and whether the cost is worth a dependency. So here are the four places the plain class quietly gives out.&lt;/p&gt;

&lt;p&gt;None of this is Rails-specific, by the way. briefly is a plain-Ruby facade; Rails is one optional pack it ships. I'll lean on Rails for examples because that's where most of us feel the pain, but the gem doesn't require it and the ideas don't either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plain class already beats the clever options
&lt;/h2&gt;

&lt;p&gt;Before we compare a class to a gem, notice the class already beats the two "smarter" things you might reach for.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;method_missing&lt;/code&gt;. It's the reflex when you want &lt;code&gt;App.anything&lt;/code&gt; to work without declaring it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method_missing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, and then quietly lies to everything that inspects it. &lt;code&gt;App.respond_to?(:redis)&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, because there's no method there. Console completion shows nothing. Jump-to-definition lands nowhere. And in a test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:redis&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;and_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RSpec's verifying doubles refuse. Their docs say it plainly: they "do not support methods which the class reports to not exist... This is commonly the case when &lt;code&gt;method_missing&lt;/code&gt; is used." You can change what the class reports: pair &lt;code&gt;method_missing&lt;/code&gt; with &lt;code&gt;respond_to_missing?&lt;/code&gt; (the standard discipline; RuboCop has a cop that insists on it) and &lt;code&gt;respond_to?&lt;/code&gt; flips to true, which satisfies verifying doubles too. What no pairing fixes is that there is still no method behind the name: nothing to tab-complete, nothing to jump to, no arity to check. A call working and a method existing are different things.&lt;/p&gt;

&lt;p&gt;The other option is a dependency-injection container, like dry-rb's &lt;code&gt;dry-container&lt;/code&gt;. It's a fine tool, but a different shape: you register objects under string keys and resolve them at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"redis"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;REDIS_POOL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"redis"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# not container.redis&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You look up &lt;code&gt;"redis"&lt;/code&gt;, a string, not &lt;code&gt;redis&lt;/code&gt;, a method. That buys you container-agnostic wiring (dry-system will even auto-register components from file paths), and it costs you the jump-to-definition and tab-completion the plain class had for free.&lt;/p&gt;

&lt;p&gt;So the spectrum looks like this, and the plain class is already sitting in the good spot:&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;&lt;code&gt;method_missing&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;plain class / facade&lt;/th&gt;
&lt;th&gt;DI container&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;respond_to?&lt;/code&gt; sees it&lt;/td&gt;
&lt;td&gt;with &lt;code&gt;respond_to_missing?&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;n/a (keyed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;console tab-completion&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jump-to-definition&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;arity (argument count) checked&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;past the lookup only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verifying doubles work&lt;/td&gt;
&lt;td&gt;with &lt;code&gt;respond_to_missing?&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;stubs + injection instead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two cells deserve their fine print. The container's lookup is a string key, which is what completion and jump-to-definition can't follow; the object it returns has perfectly real methods, and dry-container ships a test-stub mode besides, while constructor injection lets a spec hand in a verifying &lt;code&gt;instance_double&lt;/code&gt; directly. And the &lt;code&gt;method_missing&lt;/code&gt; column earns its two upgrades only when someone remembered to write &lt;code&gt;respond_to_missing?&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Interestingly, Rails itself lands in the middle column. &lt;code&gt;ActiveSupport::CurrentAttributes&lt;/code&gt; compiles real accessor methods with string codegen rather than reaching for &lt;code&gt;method_missing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which means "briefly gives you real methods" is not a reason to use it. The class already has those. (briefly keeps them: its shortcuts sit in the same column, so everything in that table works on &lt;code&gt;App&lt;/code&gt; exactly as on the plain class. One asterisk, and it's the plain class's win: shortcuts are compiled at runtime, so static analysis (Sorbet, Steep) can't see them the way it sees a hand-written &lt;code&gt;def&lt;/code&gt;. briefly ships RBS signatures for its own API and a pattern for declaring your shortcuts' types, but on a strictly-typed codebase that's real friction the class doesn't have.) The reasons are elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooth 1: memoization you can trust
&lt;/h2&gt;

&lt;p&gt;Here is the first place the hand-rolled version can draw blood. You memoize something expensive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@cache&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="n"&gt;build_cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a strawman. It's &lt;a href="https://github.com/discourse/discourse/blob/main/lib/discourse.rb" rel="noopener noreferrer"&gt;how Discourse does it&lt;/a&gt;, in a real 1,300-line &lt;code&gt;Discourse&lt;/code&gt; module that is exactly the facade we're talking about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;
  &lt;span class="vi"&gt;@cache&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;GlobalSetting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;skip_redis?&lt;/span&gt;
      &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MemoryStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@x ||=&lt;/code&gt; is the memoization idiom in every large app I looked at: Discourse, Mastodon, GitLab all reach for it. And it has two quiet bugs.&lt;/p&gt;

&lt;p&gt;The first is threads. &lt;code&gt;@cache ||= build&lt;/code&gt; is a check and then a set, and nothing makes those atomic: one thread can read &lt;code&gt;@cache&lt;/code&gt; as nil and, before it assigns, another reads nil too, so both run &lt;code&gt;build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Whether that actually happens on MRI (the standard Ruby interpreter) comes down to one thing, and it isn't the one people usually reach for. MRI runs one thread at a time under its global VM lock, the GVL, but it &lt;em&gt;preempts&lt;/em&gt; threads: the scheduler can pause one at a method call or a loop turn and let another run. A build that does I/O yields the moment it waits, and a pure-Ruby build gets paused too if it runs long enough to meet the timer. So the memo races when &lt;code&gt;build&lt;/code&gt; is slow enough to be interrupted between the check and the set, and slips through clean only when it's short enough to finish in one time-slice.&lt;/p&gt;

&lt;p&gt;I wrote the race out four ways, in three pieces that stack into one runnable file. First, the victim and the trigger: the classic idiom in a tiny facade, and a &lt;code&gt;hammer&lt;/code&gt; that parks twenty-five threads on a &lt;code&gt;Queue&lt;/code&gt; gate and releases them at once, so all of them hit the cold memo in the same instant:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"briefly"&lt;/span&gt;

&lt;span class="no"&gt;THREADS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;

&lt;span class="c1"&gt;# The classic idiom. The check-then-set is not atomic across threads.&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RawFacade&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&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;build&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@build&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;catalog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@catalog&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="vi"&gt;@build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hammer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
  &lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;THREADS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="no"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;      &lt;span class="c1"&gt;# park here until the gate opens&lt;/span&gt;
      &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;catalog&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="no"&gt;THREADS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="ss"&gt;:go&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:join&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, the measurement. Each build bumps a counter so we can see how many times it really ran, and the counter takes its own &lt;code&gt;Mutex&lt;/code&gt;: &lt;code&gt;runs += 1&lt;/code&gt; is itself a check-then-set, and a demo about a data race can't afford to undercount its own evidence with one:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;counting&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;build&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Mutex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
  &lt;span class="n"&gt;facade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RawFacade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
    &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synchronize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;runs&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="n"&gt;result&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="n"&gt;hammer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;facade&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;runs&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Last, the contestants: three builds through the raw idiom, then the same I/O build through briefly's &lt;code&gt;.memoize&lt;/code&gt;. The I/O build is a &lt;code&gt;sleep&lt;/code&gt; standing in for a connect, sized wide enough that all twenty-five threads clear the nil check before any finishes, so it collides every time. That 25-of-25 is a forced maximum to make the shape unmissable, and the forced part is the gate (twenty-five arrivals in the same instant), not the sleep. (I also measured the unforced version: the same harness with a 2ms build and the arrivals scattered across 50ms re-ran the build one to three times across ten trials: a caller duplicates the build when it shows up while the first build is still running, and scattered arrivals rarely do.)&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;io_runs&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="no"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;            &lt;span class="c1"&gt;# I/O: yields the GVL at once&lt;/span&gt;
&lt;span class="n"&gt;short_runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;100&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="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="c1"&gt;# short pure: one time-slice&lt;/span&gt;
&lt;span class="n"&gt;long_runs&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counting&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;                                    &lt;span class="c1"&gt;# long pure: gets preempted&lt;/span&gt;
  &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="mi"&gt;40_000_000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;brief_runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;brief_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Mutex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:catalog&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;
    &lt;span class="n"&gt;brief_lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;synchronize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;brief_runs&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="no"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;hammer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"raw `@x ||=`, I/O build (sleep):   &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;io_runs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; run(s) across &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;THREADS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; racing threads"&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"raw `@x ||=`, short pure build:    &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;short_runs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; run(s) across &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;THREADS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; racing threads"&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"raw `@x ||=`, long pure build:     &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;long_runs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; run(s) across &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;THREADS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; racing threads"&lt;/span&gt;
&lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"briefly `.memoize`:                &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;brief_runs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; run(s) across &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;THREADS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; racing threads"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stack the three pieces in one file with &lt;code&gt;gem "briefly"&lt;/code&gt; and run it. It prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw `@x ||=`, I/O build (sleep):   25 run(s) across 25 racing threads
raw `@x ||=`, short pure build:    1 run(s) across 25 racing threads
raw `@x ||=`, long pure build:     25 run(s) across 25 racing threads
briefly `.memoize`:                1 run(s) across 25 racing threads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The short pure build ran once; the long pure build, no I/O at all, still raced, because it ran long enough to be preempted (its body is a deliberately oversized loop, forced the same way the &lt;code&gt;sleep&lt;/code&gt; is; what matters is that it races at all, not the count). So the line isn't I/O versus pure, it's fast versus slow.&lt;/p&gt;

&lt;p&gt;Most of your memos are a fast computed value and are genuinely fine on MRI; this is no reason to distrust every &lt;code&gt;@x ||=&lt;/code&gt; you've written. (Whether a real memo like Discourse's &lt;code&gt;Cache.new&lt;/code&gt; above races comes down to the same line: does building it do the slow work up front, or defer it to first use? These apps also wrap the pools themselves in &lt;code&gt;ConnectionPool&lt;/code&gt;, which is where their thread-safety actually lives.) And this whole picture is MRI's. JRuby and TruffleRuby run threads genuinely in parallel, no global lock narrowing the window, so the idiom has even less cover there.&lt;/p&gt;

&lt;p&gt;The check-and-set was never atomic to begin with, which is why briefly's &lt;code&gt;.memoize&lt;/code&gt; takes a real lock instead of trusting the runtime: the body runs once no matter how many threads race it or what it does. After that first write, reads hit a frozen snapshot and don't lock at all. The lock is one reentrant &lt;code&gt;Monitor&lt;/code&gt; per facade, so a memoized body can read another memoized shortcut on the same facade without deadlocking itself. (The flip side of one lock per facade: two shortcuts on the same facade never build in parallel.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;build_cache&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Threads share a memo by accident one way; &lt;code&gt;fork&lt;/code&gt; is the other. A forking server (Puma in cluster mode with &lt;code&gt;preload_app!&lt;/code&gt;, Unicorn) boots the app once and forks workers from it, so a connection memoized before the fork arrives in every worker already open (the classic shared-socket bug), and no memoization scheme can know a fork happened; briefly has no fork hooks. The fix is the usual one: don't call connection-holding shortcuts during boot, or clear the facade in the server's worker-boot hook with &lt;code&gt;App.briefly.clear_memos!&lt;/code&gt;. (&lt;code&gt;App.briefly&lt;/code&gt; is the management handle: briefly keeps every non-shortcut method behind that one accessor so your shortcut names stay free.)&lt;/p&gt;

&lt;p&gt;The second bug is staleness. If you memoize something that holds a reloadable class, like &lt;code&gt;ApplicationController.helpers&lt;/code&gt;, it goes stale the moment Rails reloads your code in development. You edit a helper, refresh, and nothing changes, because &lt;code&gt;@helpers&lt;/code&gt; is pinned to the class from before the reload. briefly's Rails pack clears memos on every reload (and never in production, where nothing reloads), so the thing you cached is dropped exactly when it might have changed. The plain &lt;code&gt;@x ||=&lt;/code&gt; has no idea a reload happened. (Where a value is cheap the pack doesn't memoize at all: its &lt;code&gt;helpers&lt;/code&gt; and &lt;code&gt;routes&lt;/code&gt; shortcuts are live lookups, re-read on every call, so there's nothing to go stale.)&lt;/p&gt;

&lt;p&gt;briefly's memoization also refuses to do the wrong thing. Shortcuts take arguments fine (&lt;code&gt;shortcut(:user) { |id| User.find(id) }&lt;/code&gt; is a real one-argument method, arity and all), but memoize one and it raises when you define the facade, not later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; Briefly::Error: cannot memoize user: its body takes arguments&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because a value cached against nothing is all memoization can mean. And it caches &lt;code&gt;nil&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt; correctly, which the bare &lt;code&gt;||=&lt;/code&gt; famously does not.&lt;/p&gt;

&lt;p&gt;The test suite leans on the same two facts. Shortcuts are real methods, so &lt;code&gt;allow(App).to receive(:cache)&lt;/code&gt; passes verifying doubles exactly as it would on the plain class. And memos are process-global state, so drop them between examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear_memos!&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tooth 2: the first namespace you reach for
&lt;/h2&gt;

&lt;p&gt;This is the one that made me stop hand-rolling. A flat bag of &lt;code&gt;App.redis&lt;/code&gt;, &lt;code&gt;App.cache&lt;/code&gt;, &lt;code&gt;App.store&lt;/code&gt; is fine right up until you need your first namespace, and real apps need them fast.&lt;/p&gt;

&lt;p&gt;Look at what a big app's grab-bag actually holds. Even Mastodon, the leanest of the ones I checked, has three separate Redis configs (base, sidekiq, cache). GitLab has more than a dozen. And GitLab doesn't keep them in a flat bag, it nests them into modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Gitlab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;Gitlab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;Gitlab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SharedState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# ~15 of these, each a subclass of a shared Wrapper&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the natural end state. The bigger the app, the more it groups by subsystem rather than piling onto one object. Forem, the platform dev.to runs on, does the same with configuration: an &lt;code&gt;ApplicationConfig&lt;/code&gt; facade over ENV plus a family of &lt;code&gt;Settings::General&lt;/code&gt;, &lt;code&gt;Settings::SMTP&lt;/code&gt;, &lt;code&gt;Settings::RateLimit&lt;/code&gt; models, each its own little namespace. To be fair about what that evidence shows: these apps built their grouping by hand and maintain it fine: GitLab's Redis classes aren't grouped accessors, they're real classes carrying pooling and per-store config that no facade DSL would replace. The claim isn't that big apps fail without a gem. It's that grouping is where everyone ends up, and in a plain class every step of it is yours to build.&lt;/p&gt;

&lt;p&gt;In a plain class, your first namespace means a nested module, or a second class, or prefixing method names by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Redis&lt;/span&gt;          &lt;span class="c1"&gt;# the namespace is a nested module&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;Redis&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CACHE_POOL&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SESSIONS_POOL&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, but you're now maintaining that grouping by hand, and the day you want the memoization and reset-on-reload from Tooth 1 inside it, you're hand-rolling those again per module. briefly has &lt;code&gt;namespace&lt;/code&gt;, and the child is a real facade you can pass around:&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/assets%2Fgrab-bag-grouping.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/assets%2Fgrab-bag-grouping.png" alt="A flat facade accreting accessors, then splitting into grouped namespaces"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;CACHE_POOL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:sessions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;SESSIONS_POOL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redis&lt;/span&gt;            &lt;span class="c1"&gt;# =&amp;gt; a real Briefly::Facade, not a method_missing trick&lt;/span&gt;
&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;      &lt;span class="c1"&gt;# =&amp;gt; CACHE_POOL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;App.redis&lt;/code&gt; is a value, &lt;code&gt;App.redis.cache&lt;/code&gt; is a real method on it, and the management claims are concrete APIs, not promises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear_memos!&lt;/span&gt;    &lt;span class="c1"&gt;# drops every memo, root and namespaces alike&lt;/span&gt;
&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;    &lt;span class="c1"&gt;# add or replace shortcuts on a live facade&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;NEW_STORE&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clearing cascades through the whole tree. And a &lt;code&gt;configure&lt;/code&gt; pass is atomic across every namespace: the new configuration is built off to the side and swapped in at the end, so a pass that raises anywhere leaves the whole thing exactly as it was. That's a chunk of correctness you'd otherwise be writing and testing yourself the day you split &lt;code&gt;App.redis&lt;/code&gt; into &lt;code&gt;App.redis.cache&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooth 3: packs, so you write it once
&lt;/h2&gt;

&lt;p&gt;Real config repeats across apps. The &lt;code&gt;redis&lt;/code&gt;, &lt;code&gt;cache&lt;/code&gt;, &lt;code&gt;logger&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt; block you set up in one service is the same block you set up in the next. In a plain class you copy-paste &lt;code&gt;def self.&lt;/code&gt; across three repos and they drift.&lt;/p&gt;

&lt;p&gt;A briefly pack is any object that responds to &lt;code&gt;install&lt;/code&gt;. That's the whole protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;RedisPack&lt;/span&gt;
  &lt;span class="kp"&gt;module_function&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;REDIS_POOL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;Api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="no"&gt;RedisPack&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;Api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redis&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; REDIS_POOL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two conveniences round the protocol out. You can register a short string name for a pack, and packs take options. Give &lt;code&gt;install&lt;/code&gt; a keyword parameter and &lt;code&gt;use&lt;/code&gt; forwards it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"myapp/redis"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;RedisPack&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# once, e.g. in an initializer&lt;/span&gt;

&lt;span class="no"&gt;Api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="s2"&gt;"myapp/redis"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"REDIS_URL"&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;(That version of &lt;code&gt;RedisPack&lt;/code&gt; declares &lt;code&gt;def install(builder, url:)&lt;/code&gt;.) A pack isn't take-it-or-leave-it, either: declare a shortcut again after &lt;code&gt;use&lt;/code&gt; and the last declaration wins, silently. That's the intended way to override one piece of a pack, and worth knowing before you shadow something by accident.&lt;/p&gt;

&lt;p&gt;Nothing here touches Rails; it's a plain module. The Rails support you may have seen (&lt;code&gt;use "rails"&lt;/code&gt;, which gives you &lt;code&gt;config&lt;/code&gt;, &lt;code&gt;logger&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt; predicates, a &lt;code&gt;db&lt;/code&gt; namespace, credentials, an error reporter, the live &lt;code&gt;helpers&lt;/code&gt;/&lt;code&gt;routes&lt;/code&gt; lookups from Tooth 1, and more) is just a pack shipped in the box, autoloaded only if you name it. You can write your own for your Stripe client, your feature flags, your search index, and share it.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;db&lt;/code&gt; namespace is the widest thing in the box, and it's the one I'd point at if you're deciding whether the Rails pack earns its keep. Aim it at one Active Record class and the multi-database surface comes back as shortcuts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="s2"&gt;"rails"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# `db` is a namespace it ships&lt;/span&gt;

&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;txn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"insert into audits (msg) values (?)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hi"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"select count(*) from events"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# to the replica, if you run one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connection&lt;/code&gt;/&lt;code&gt;conn&lt;/code&gt; lease a connection and release it at block exit; &lt;code&gt;transaction&lt;/code&gt;/&lt;code&gt;txn&lt;/code&gt; wrap a transaction; &lt;code&gt;reading&lt;/code&gt;/&lt;code&gt;writing&lt;/code&gt; pin the Rails role so reads land on a replica and writes on the primary; &lt;code&gt;select&lt;/code&gt; runs a read through &lt;code&gt;select_all&lt;/code&gt;, and &lt;code&gt;query&lt;/code&gt; runs arbitrary SQL (writes and DDL included) through &lt;code&gt;exec_query&lt;/code&gt;. One safety line: a statement &lt;em&gt;with binds&lt;/em&gt; goes through &lt;code&gt;sanitize_sql_array&lt;/code&gt;, a bindless string does not. The &lt;code&gt;select&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; split is about the query cache and intent, not a runtime read/write guard, so an interpolated string is still yours to get wrong.&lt;/p&gt;

&lt;p&gt;And because a facade is cheap, you make several. One per area, each composing only the packs it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="s2"&gt;"rails"&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:audit_log&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;AuditLog&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;Worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="s2"&gt;"rails/env"&lt;/span&gt;
  &lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="s2"&gt;"rails/reload"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two independent facades, no shared state, each with its own memo cells and handlers. (Own locks, too, which cuts one boundary: two facades whose memoized bodies call each other can deadlock, like any pair of mutually-locking objects. Keep cross-facade calls out of memoized bodies.) You could share config another way, a module of &lt;code&gt;def self.&lt;/code&gt; methods you &lt;code&gt;extend&lt;/code&gt; into each app, and for a fixed handful of accessors that's fine. What a mixin doesn't give you is composition: packs stack (use several, pass each options), and every facade takes only the ones it needs, where an &lt;code&gt;extend&lt;/code&gt;ed module hands every includer the same fixed surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooth 4: errors, aliases, and one honest footgun
&lt;/h2&gt;

&lt;p&gt;A few smaller things you'd otherwise hand-roll.&lt;/p&gt;

&lt;p&gt;Aliases that share one memo cell, so &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt; are the same computed value, not two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;expensive&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scoped error handling, where the handler's return value becomes the shortcut's value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;REDIS_POOL&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;rescue_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Redis&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BaseError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can register a handler per shortcut, per facade, or globally, and they resolve most-specific first. (One syntax note for the standalone form: &lt;code&gt;rescue_from StandardError { ... }&lt;/code&gt; binds the block to &lt;code&gt;StandardError&lt;/code&gt;, not to &lt;code&gt;rescue_from&lt;/code&gt;. That's Ruby's precedence, not briefly's. So write &lt;code&gt;rescue_from(StandardError) { ... }&lt;/code&gt; or use do/end.) Which brings me to the sharpest edge, the kind of thing a facade invites you to get wrong. A facade-wide handler over &lt;code&gt;StandardError&lt;/code&gt; catches your app's errors and &lt;em&gt;also catches your own bugs&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;App&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;credentaisl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;secret_key_base&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# typo: credentaisl&lt;/span&gt;
  &lt;span class="n"&gt;rescue_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;App&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;secret&lt;/span&gt;   &lt;span class="c1"&gt;# =&amp;gt; nil. No exception, no log, no clue.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the moment the handler runs, a &lt;code&gt;NoMethodError&lt;/code&gt; from a typo is indistinguishable from one the handler was written to absorb. A library can't read your intent from a backtrace, so briefly doesn't try. It documents the trap and points you at the narrow handlers that don't have it: scope the rescue to the shortcut that can actually fail, match the narrowest error class, and if you must go facade-wide, log and re-raise. I decided a sharp edge you can see beats a clever fix that's silently wrong a fraction of the time. That's a recurring theme in the gem, and it's worth more than any feature.&lt;/p&gt;

&lt;p&gt;There's a second edge, subtler. Memoization doesn't compose with recovery. A rescue-backed shortcut that fails is careful &lt;em&gt;not&lt;/em&gt; to cache its fallback: it retries next call and recovers. But if a memoized shortcut read it during that first, failing call, the reader has already baked the fallback into its own cached value, and pins it for the life of the process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Briefly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:flaky&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;down?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;raise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"down"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"up"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rescue_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"unknown"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt;
  &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"build &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;flaky&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;memoize&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="c1"&gt;# first call, flaky is down  -&amp;gt; summary caches "build unknown"&lt;/span&gt;
&lt;span class="c1"&gt;# later, flaky recovers to "up" -&amp;gt; summary stays "build unknown", forever&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The inner shortcut did the right thing and came back; the outer one had already committed. It's the same rule as &lt;code&gt;.memoize&lt;/code&gt; holding &lt;code&gt;nil&lt;/code&gt; and &lt;code&gt;false&lt;/code&gt;: a fallback is a real value, and memoize's job is to keep the value it got. Worth a beat of thought before you &lt;code&gt;.memoize&lt;/code&gt; something that reads a shortcut which can fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gem underneath: candor
&lt;/h2&gt;

&lt;p&gt;There's one thing briefly must not cost you compared to the plain class, and that's the honesty of its methods. A hand-written &lt;code&gt;def self.config&lt;/code&gt; reports its real arity, its real parameters, and a &lt;code&gt;source_location&lt;/code&gt; pointing at your code. If briefly's shortcuts were metaprogrammed stand-ins that reported &lt;code&gt;(*args)&lt;/code&gt; and pointed into the gem, you'd have traded away exactly what made the class good.&lt;/p&gt;

&lt;p&gt;Keeping that honesty turned out to be the hardest part of the whole project, and it lives in a second gem, &lt;a href="https://github.com/svyatov/candor" rel="noopener noreferrer"&gt;candor&lt;/a&gt;, which briefly depends on and which has no dependencies of its own. It turns a block into a real method that reports the block's own signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;mod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
&lt;span class="no"&gt;Candor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:greet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;greeting: &lt;/span&gt;&lt;span class="s2"&gt;"hi"&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;greeting&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&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="n"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:greet&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="nf"&gt;parameters&lt;/span&gt;       &lt;span class="c1"&gt;# =&amp;gt; [[:req, :__p0], [:key, :greeting]]&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;source_location&lt;/span&gt;  &lt;span class="c1"&gt;# =&amp;gt; your block, not the gem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it pulls that off is a story of its own. Ruby's reflection lies about blocks in more ways than you'd expect, and candor exists to un-lie each one. Even that &lt;code&gt;:__p0&lt;/code&gt; in the output, where a hand-written &lt;code&gt;def greet(name, ...)&lt;/code&gt; would say &lt;code&gt;:name&lt;/code&gt;, is a deliberate seam with a reason behind it. The seams, the trade-offs, and the parts of Ruby that fought back all get a follow-up post of their own. For this one, the takeaway is simple: briefly's shortcuts are real methods, and honesty is not part of the price.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, when is it worth it?
&lt;/h2&gt;

&lt;p&gt;If your &lt;code&gt;App&lt;/code&gt; is five real methods, memoizes nothing that reloads, lives in one app, and never grew a namespace, keep the class. Same call if your team leans on Sorbet or Steep: a hand-written &lt;code&gt;def&lt;/code&gt; is visible to the type checker, and a runtime-compiled shortcut isn't. You don't need a dependency for that, and I'd think less of the gem if it pretended you did. Even Rails' own &lt;code&gt;CurrentAttributes&lt;/code&gt;, a facade blessed enough to ship in the framework, tells you in its docs not to overdo it: "only a few, top-level globals."&lt;/p&gt;

&lt;p&gt;briefly earns its place at the first of these you hit: you memoize something expensive and want it correct under threads and reloads; you split one accessor into a namespace; you find yourself copying the same setup into a second app; or you add a rescue and want it scoped instead of swallowing everything. That's not day one. It's the day the drawer by the door stops closing, and you notice you've been maintaining the drawer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"briefly"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ruby 3.2 or newer, one dependency, Rails optional (the Rails pack wants Rails 7.2+). It's &lt;a href="https://github.com/svyatov/briefly" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; and &lt;a href="https://rubygems.org/gems/briefly" rel="noopener noreferrer"&gt;RubyGems&lt;/a&gt; under MIT. Write one less &lt;code&gt;def self.&lt;/code&gt;, but only once you'd have written the tenth.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The complete guide to `rails new` in Rails 8.1</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Tue, 03 Mar 2026 10:23:12 +0000</pubDate>
      <link>https://dev.to/svyatov/the-complete-guide-to-rails-new-in-rails-81-346c</link>
      <guid>https://dev.to/svyatov/the-complete-guide-to-rails-new-in-rails-81-346c</guid>
      <description>&lt;p&gt;&lt;code&gt;rails new --help&lt;/code&gt; prints 30+ options. Most tutorials stop at &lt;code&gt;rails new myapp -d postgresql&lt;/code&gt; and move on. This post covers every Rails 8.1 option - what it does, when to use it, and what most people pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  App Mode
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--api&lt;/code&gt;&lt;/strong&gt; - Strips views, cookies, sessions, asset pipeline, &lt;a href="https://hotwired.dev/" rel="noopener noreferrer"&gt;Hotwire&lt;/a&gt;, CSS, and JavaScript. Use for pure JSON or GraphQL backends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--minimal&lt;/code&gt;&lt;/strong&gt; - Generates the smallest possible app. Skips Action Mailer, Action Mailbox, Action Text, Active Job, Active Storage, Action Cable, Hotwire, &lt;a href="https://github.com/rails/jbuilder" rel="noopener noreferrer"&gt;Jbuilder&lt;/a&gt;, tests, system tests, &lt;a href="https://kamal-deploy.org/" rel="noopener noreferrer"&gt;Kamal&lt;/a&gt;, &lt;a href="https://github.com/basecamp/thruster" rel="noopener noreferrer"&gt;Thruster&lt;/a&gt;, Solid, and &lt;a href="https://github.com/Shopify/bootsnap" rel="noopener noreferrer"&gt;Bootsnap&lt;/a&gt;. A good starting point when you want to add only what you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database (&lt;code&gt;--database=X&lt;/code&gt;)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Adapter&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://www.sqlite.org/" rel="noopener noreferrer"&gt;sqlite3&lt;/a&gt;&lt;/strong&gt; (default)&lt;/td&gt;
&lt;td&gt;Zero setup, no server, works with Solid stack. &lt;a href="https://37signals.com/" rel="noopener noreferrer"&gt;37signals&lt;/a&gt; runs &lt;a href="https://once.com/" rel="noopener noreferrer"&gt;ONCE&lt;/a&gt; products on it in production.&lt;/td&gt;
&lt;td&gt;Single-writer, no multi-server scaling.&lt;/td&gt;
&lt;td&gt;Solo devs, prototypes, single-server apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://www.postgresql.org/" rel="noopener noreferrer"&gt;postgresql&lt;/a&gt;&lt;/strong&gt; ⭐&lt;/td&gt;
&lt;td&gt;Full-featured (JSONB, arrays, full-text search), every PaaS supports it, best managed DB options.&lt;/td&gt;
&lt;td&gt;Requires a running server.&lt;/td&gt;
&lt;td&gt;Most production apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://www.mysql.com/" rel="noopener noreferrer"&gt;mysql&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Widely deployed, mature.&lt;/td&gt;
&lt;td&gt;No native arrays, JSON can't be indexed directly (unlike PostgreSQL's JSONB).&lt;/td&gt;
&lt;td&gt;Existing MySQL infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/trilogy-libraries/trilogy" rel="noopener noreferrer"&gt;trilogy&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Modern MySQL client by GitHub, no C library dependency, simpler install. GitHub &amp;amp; Shopify use it in production.&lt;/td&gt;
&lt;td&gt;Newer, smaller ecosystem.&lt;/td&gt;
&lt;td&gt;New MySQL projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://mariadb.org/" rel="noopener noreferrer"&gt;mariadb&lt;/a&gt;-mysql&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;For MariaDB specifically with &lt;a href="https://github.com/brianmario/mysql2" rel="noopener noreferrer"&gt;mysql2&lt;/a&gt; adapter.&lt;/td&gt;
&lt;td&gt;Same as mysql.&lt;/td&gt;
&lt;td&gt;Existing MariaDB setups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mariadb-trilogy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;For MariaDB specifically with Trilogy adapter.&lt;/td&gt;
&lt;td&gt;Same as trilogy.&lt;/td&gt;
&lt;td&gt;New MariaDB projects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PostgreSQL is the most common choice. When in doubt, pick it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-active-record&lt;/code&gt; / &lt;code&gt;-O&lt;/code&gt;&lt;/strong&gt; - Drops the ORM entirely. Cascading effect: also skips database, Action Mailbox, Action Text, and Active Storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend Stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  JavaScript (&lt;code&gt;--javascript=X&lt;/code&gt; / &lt;code&gt;-j&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/rails/importmap-rails" rel="noopener noreferrer"&gt;importmap&lt;/a&gt;&lt;/strong&gt; (default) ⭐&lt;/td&gt;
&lt;td&gt;No Node.js, no build step, browser-native ES modules, HTTP/2 parallel downloads.&lt;/td&gt;
&lt;td&gt;No tree-shaking, no TypeScript/JSX transpilation.&lt;/td&gt;
&lt;td&gt;Standard Rails + Hotwire apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://esbuild.github.io/" rel="noopener noreferrer"&gt;esbuild&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Extremely fast (Go-based), handles TypeScript/JSX, mature.&lt;/td&gt;
&lt;td&gt;Requires Node.js.&lt;/td&gt;
&lt;td&gt;Apps needing a bundler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://bun.sh/" rel="noopener noreferrer"&gt;bun&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~1.75x faster than esbuild, doubles as package manager + bundler.&lt;/td&gt;
&lt;td&gt;Newer, smaller ecosystem than esbuild.&lt;/td&gt;
&lt;td&gt;Speed-first, Bun ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://webpack.js.org/" rel="noopener noreferrer"&gt;webpack&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full-featured, huge plugin ecosystem.&lt;/td&gt;
&lt;td&gt;Slow, complex config, legacy choice.&lt;/td&gt;
&lt;td&gt;Not recommended for new projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://rollupjs.org/" rel="noopener noreferrer"&gt;rollup&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Good for libraries, ES module focused.&lt;/td&gt;
&lt;td&gt;Niche in Rails context.&lt;/td&gt;
&lt;td&gt;Not recommended for new projects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Importmap is the Rails default for a reason - it eliminates Node.js from your stack entirely. Reach for esbuild or bun only when you need TypeScript, JSX, or tree-shaking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-javascript&lt;/code&gt; / &lt;code&gt;-J&lt;/code&gt;&lt;/strong&gt; - No JS setup at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  CSS (&lt;code&gt;--css=X&lt;/code&gt; / &lt;code&gt;-c&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;(none)&lt;/strong&gt; (default)&lt;/td&gt;
&lt;td&gt;No dependencies, write plain CSS.&lt;/td&gt;
&lt;td&gt;No utilities or components.&lt;/td&gt;
&lt;td&gt;Minimal apps, custom design systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;tailwind&lt;/a&gt;&lt;/strong&gt; ⭐&lt;/td&gt;
&lt;td&gt;Rails-blessed (&lt;a href="https://github.com/rails/tailwindcss-rails" rel="noopener noreferrer"&gt;&lt;code&gt;tailwindcss-rails&lt;/code&gt;&lt;/a&gt; gem), no Node.js required, v4 is 5x faster with CSS-first config.&lt;/td&gt;
&lt;td&gt;Verbose HTML classes, learning curve.&lt;/td&gt;
&lt;td&gt;Most new Rails apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://getbootstrap.com/" rel="noopener noreferrer"&gt;bootstrap&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pre-built components, familiar to most devs.&lt;/td&gt;
&lt;td&gt;Requires Node.js (via &lt;a href="https://github.com/rails/cssbundling-rails" rel="noopener noreferrer"&gt;&lt;code&gt;cssbundling-rails&lt;/code&gt;&lt;/a&gt;), heavier.&lt;/td&gt;
&lt;td&gt;Apps needing ready-made UI components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://bulma.io/" rel="noopener noreferrer"&gt;bulma&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CSS-only (no JS), clean modern design.&lt;/td&gt;
&lt;td&gt;Requires Node.js via &lt;code&gt;cssbundling-rails&lt;/code&gt;, smaller community.&lt;/td&gt;
&lt;td&gt;Lightweight component needs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://postcss.org/" rel="noopener noreferrer"&gt;postcss&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Plugin-based CSS transforms, flexible.&lt;/td&gt;
&lt;td&gt;Requires Node.js, config overhead.&lt;/td&gt;
&lt;td&gt;Custom CSS toolchains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://sass-lang.com/" rel="noopener noreferrer"&gt;sass&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Variables, nesting, mixins, mature.&lt;/td&gt;
&lt;td&gt;Requires Node.js (&lt;a href="https://github.com/rails/propshaft" rel="noopener noreferrer"&gt;Propshaft&lt;/a&gt; doesn't compile Sass).&lt;/td&gt;
&lt;td&gt;Teams already using Sass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No CSS framework is included by default. Tailwind is the most popular choice for new Rails apps - the &lt;code&gt;tailwindcss-rails&lt;/code&gt; gem runs standalone, no Node.js required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Asset Pipeline (&lt;code&gt;--asset-pipeline=X&lt;/code&gt;)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/rails/propshaft" rel="noopener noreferrer"&gt;propshaft&lt;/a&gt;&lt;/strong&gt; (default) ⭐&lt;/td&gt;
&lt;td&gt;Modern, lightweight - just fingerprinting and serving. Precompiles in seconds vs minutes.&lt;/td&gt;
&lt;td&gt;No built-in preprocessing (delegates to external tools).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href="https://github.com/rails/sprockets" rel="noopener noreferrer"&gt;sprockets&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in Sass/CoffeeScript/ERB compilation, &lt;code&gt;//= require&lt;/code&gt; directives.&lt;/td&gt;
&lt;td&gt;Slow, complex, legacy.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Propshaft does one thing well: fingerprint and serve assets. If you need Sass or CoffeeScript compilation, Sprockets handles it - but you probably don't in 2025.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-asset-pipeline&lt;/code&gt; / &lt;code&gt;-A&lt;/code&gt;&lt;/strong&gt; - No asset pipeline at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hotwire (&lt;code&gt;--skip-hotwire&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://turbo.hotwired.dev/" rel="noopener noreferrer"&gt;Turbo&lt;/a&gt; + &lt;a href="https://stimulus.hotwired.dev/" rel="noopener noreferrer"&gt;Stimulus&lt;/a&gt; - the default Rails frontend framework. Turbo 8 introduced morphing (DOM diffing via &lt;a href="https://github.com/bigskysoftware/idiomorph" rel="noopener noreferrer"&gt;idiomorph&lt;/a&gt;) that makes full-page refreshes feel SPA-smooth. Skip only if you're using a JS SPA framework (React, Vue) or building an API-only app (auto-skipped with &lt;code&gt;--api&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solid Stack &amp;amp; Deployment
&lt;/h2&gt;

&lt;p&gt;These are Rails 8 additions that replace external services with database-backed alternatives.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--skip-solid&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/rails/solid_cache" rel="noopener noreferrer"&gt;Solid Cache&lt;/a&gt; + &lt;a href="https://github.com/rails/solid_queue" rel="noopener noreferrer"&gt;Solid Queue&lt;/a&gt; + &lt;a href="https://github.com/rails/solid_cable" rel="noopener noreferrer"&gt;Solid Cable&lt;/a&gt; - replaces &lt;a href="https://redis.io/" rel="noopener noreferrer"&gt;Redis&lt;/a&gt; for caching, background jobs, and WebSockets. All database-backed, works with SQLite, PostgreSQL, and MySQL. 37signals runs 20M jobs/day on Solid Queue for &lt;a href="https://www.hey.com/" rel="noopener noreferrer"&gt;HEY&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solid Cache&lt;/strong&gt; - database-backed caching. Higher latency than Redis RAM, but terabytes of SSD capacity at a fraction of the cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solid Queue&lt;/strong&gt; - uses &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; for efficient polling. Replaces &lt;a href="https://sidekiq.org/" rel="noopener noreferrer"&gt;Sidekiq&lt;/a&gt;/&lt;a href="https://github.com/resque/resque" rel="noopener noreferrer"&gt;Resque&lt;/a&gt; for most apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solid Cable&lt;/strong&gt; - database-backed Action Cable. Polls every 100ms. Fine for moderate real-time needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Skip if:&lt;/strong&gt; you already run Redis and need Sidekiq's advanced features (batches, rate limiting) or sub-millisecond cache latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--skip-kamal&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://kamal-deploy.org/" rel="noopener noreferrer"&gt;Kamal 2&lt;/a&gt; deploys Docker containers to any VPS (Hetzner, DigitalOcean, AWS EC2). Uses &lt;a href="https://github.com/basecamp/kamal-proxy" rel="noopener noreferrer"&gt;kamal-proxy&lt;/a&gt; (replaces &lt;a href="https://traefik.io/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt;) with automatic &lt;a href="https://letsencrypt.org/" rel="noopener noreferrer"&gt;Let's Encrypt&lt;/a&gt; SSL. Zero-downtime deploys, rolling restarts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip if:&lt;/strong&gt; you deploy to a PaaS (Heroku, Render, Fly.io) or use Kubernetes.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--skip-thruster&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/basecamp/thruster" rel="noopener noreferrer"&gt;Thruster&lt;/a&gt; - HTTP/2 proxy (written in Go by 37signals) that sits in front of &lt;a href="https://puma.io/" rel="noopener noreferrer"&gt;Puma&lt;/a&gt;. Provides HTTP/2, gzip compression, asset caching, X-Sendfile, and automatic SSL. Replaces &lt;a href="https://nginx.org/" rel="noopener noreferrer"&gt;Nginx&lt;/a&gt;/&lt;a href="https://caddyserver.com/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt; for simple deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip if:&lt;/strong&gt; you already have a reverse proxy (Nginx, ALB, Cloudflare) or deploy to a PaaS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frameworks You Can Skip
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Email &amp;amp; Rich Content
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-action-mailer&lt;/code&gt; / &lt;code&gt;-M&lt;/code&gt;&lt;/strong&gt; - No email sending or receiving infrastructure. Skip if your app never sends email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-action-mailbox&lt;/code&gt;&lt;/strong&gt; - No inbound email routing to controller-like mailboxes. Requires Active Record. Skip if you don't process incoming email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-action-text&lt;/code&gt;&lt;/strong&gt; - No &lt;a href="https://trix-editor.org/" rel="noopener noreferrer"&gt;Trix&lt;/a&gt; rich text editor integration. Requires Active Record. Auto-skipped in API mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  Background &amp;amp; Storage
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-active-job&lt;/code&gt;&lt;/strong&gt; - No background job framework. Also disables Solid Queue integration. Skip only if you handle no async work at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-active-storage&lt;/code&gt;&lt;/strong&gt; - No file uploads to S3/GCS/Azure. Requires Active Record. Skip if your app handles no file uploads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-action-cable&lt;/code&gt; / &lt;code&gt;-C&lt;/code&gt;&lt;/strong&gt; - No WebSocket framework. Also disables Solid Cable. Skip if you don't need real-time features.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-jbuilder&lt;/code&gt;&lt;/strong&gt; - Removes the &lt;a href="https://github.com/rails/jbuilder" rel="noopener noreferrer"&gt;Jbuilder&lt;/a&gt; JSON template DSL. Most API apps prefer &lt;code&gt;render json:&lt;/code&gt; with serializers instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing &amp;amp; Code Quality
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-test&lt;/code&gt; / &lt;code&gt;-T&lt;/code&gt;&lt;/strong&gt; - Removes &lt;a href="https://github.com/minitest/minitest" rel="noopener noreferrer"&gt;Minitest&lt;/a&gt; setup. Most people skip this to add &lt;a href="https://rspec.info/" rel="noopener noreferrer"&gt;RSpec&lt;/a&gt; instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-system-test&lt;/code&gt;&lt;/strong&gt; - Skips &lt;a href="https://github.com/teamcapybara/capybara" rel="noopener noreferrer"&gt;Capybara&lt;/a&gt; browser tests. Auto-skipped in API mode or when tests are skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-brakeman&lt;/code&gt;&lt;/strong&gt; - &lt;a href="https://brakemanscanner.org/" rel="noopener noreferrer"&gt;Brakeman&lt;/a&gt; is a static security scanner for Rails that catches SQL injection, XSS, and 33+ other vulnerability types. Runs in seconds. &lt;strong&gt;Keep it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-bundler-audit&lt;/code&gt;&lt;/strong&gt; - New in Rails 8.1. &lt;a href="https://github.com/rubysec/bundler-audit" rel="noopener noreferrer"&gt;bundler-audit&lt;/a&gt; scans your &lt;code&gt;Gemfile.lock&lt;/code&gt; for gems with known CVEs. Complements Brakeman: code scanning + dependency scanning. &lt;strong&gt;Keep it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-rubocop&lt;/code&gt;&lt;/strong&gt; - &lt;a href="https://rubocop.org/" rel="noopener noreferrer"&gt;RuboCop&lt;/a&gt;, Ruby linter, included by default since Rails 8. Keep it or swap in &lt;a href="https://github.com/standardrb/standard" rel="noopener noreferrer"&gt;Standard Ruby&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-ci&lt;/code&gt;&lt;/strong&gt; - Skips the generated &lt;a href="https://github.com/features/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; workflow. Rails 8.1 CI includes Brakeman and Bundler Audit steps by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure &amp;amp; Housekeeping
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-docker&lt;/code&gt;&lt;/strong&gt; - Skips Dockerfile, .dockerignore, and bin/docker-entrypoint. Skip if you're not containerizing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--devcontainer&lt;/code&gt;&lt;/strong&gt; - Opt-in (not included by default). Generates &lt;a href="https://code.visualstudio.com/docs/devcontainers/containers" rel="noopener noreferrer"&gt;VS Code dev container&lt;/a&gt; config with Redis, database, and Ruby all preconfigured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-bootsnap&lt;/code&gt;&lt;/strong&gt; - &lt;a href="https://github.com/Shopify/bootsnap" rel="noopener noreferrer"&gt;Bootsnap&lt;/a&gt; caches expensive computations to speed up boot time. Rarely worth skipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-dev-gems&lt;/code&gt;&lt;/strong&gt; - Skips &lt;a href="https://github.com/rails/web-console" rel="noopener noreferrer"&gt;web-console&lt;/a&gt; and similar development gems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-keeps&lt;/code&gt;&lt;/strong&gt; - Skips .keep files in empty directories. Git doesn't track empty dirs, so .keep files exist to preserve the directory structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-decrypted-diffs&lt;/code&gt;&lt;/strong&gt; - Skips git config for showing decrypted diffs of encrypted credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-git&lt;/code&gt; / &lt;code&gt;-G&lt;/code&gt;&lt;/strong&gt; - Skips &lt;code&gt;git init&lt;/code&gt;, .gitignore, and .gitattributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--skip-bundle&lt;/code&gt; / &lt;code&gt;-B&lt;/code&gt;&lt;/strong&gt; - Skips &lt;code&gt;bundle install&lt;/code&gt; after generation. Useful in CI or Docker builds where you control dependency installation separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together - Common Recipes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Full-stack SaaS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails new myapp &lt;span class="nt"&gt;-d&lt;/span&gt; postgresql &lt;span class="nt"&gt;-c&lt;/span&gt; tailwind &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-action-mailbox&lt;/span&gt; &lt;span class="nt"&gt;--skip-action-text&lt;/span&gt; &lt;span class="nt"&gt;--skip-jbuilder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workhorse setup. PostgreSQL, Tailwind, Solid stack, Kamal - all defaults. Skips Minitest (most teams add RSpec), Action Mailbox (few apps process incoming email), Action Text (add it later if you need a rich text editor), and Jbuilder (you'll use &lt;code&gt;render json:&lt;/code&gt; or a serializer).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API backend:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails new myapi &lt;span class="nt"&gt;--api&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; postgresql &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-action-mailbox&lt;/span&gt; &lt;span class="nt"&gt;--skip-jbuilder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--api&lt;/code&gt; strips views, assets, cookies, and Hotwire. Skips Minitest for RSpec. Action Mailbox and Jbuilder rarely belong in an API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-server indie app (the "one person framework"):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails new myapp &lt;span class="nt"&gt;-d&lt;/span&gt; sqlite3 &lt;span class="nt"&gt;-c&lt;/span&gt; tailwind &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-action-mailbox&lt;/span&gt; &lt;span class="nt"&gt;--skip-action-text&lt;/span&gt; &lt;span class="nt"&gt;--skip-jbuilder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite + Solid stack = no Redis, no PostgreSQL, no external services. Kamal and Thruster handle deployment and SSL. One server, one process, one database file. Add PostgreSQL later if you outgrow it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PaaS deploy (Heroku, Render, Fly.io):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails new myapp &lt;span class="nt"&gt;-d&lt;/span&gt; postgresql &lt;span class="nt"&gt;-c&lt;/span&gt; tailwind &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-action-mailbox&lt;/span&gt; &lt;span class="nt"&gt;--skip-action-text&lt;/span&gt; &lt;span class="nt"&gt;--skip-jbuilder&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--skip-kamal&lt;/span&gt; &lt;span class="nt"&gt;--skip-thruster&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same as the SaaS setup but skips Kamal and Thruster - the PaaS handles containers, routing, and SSL for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lean prototype:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails new proto &lt;span class="nt"&gt;--minimal&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; sqlite3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bare minimum: no mailer, no jobs, no cable, no Hotwire, no Kamal. Add frameworks back as you need them.&lt;/p&gt;




&lt;p&gt;If you don't want to memorize any of this, I built a tool for that. &lt;a href="https://dev.to/svyatov/i-got-tired-of-looking-up-rails-new-flags-so-i-built-a-wizard-for-it-1adl"&gt;create-rails-app&lt;/a&gt; is an interactive CLI wizard that walks you through each option, remembers your choices, and saves reusable presets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;create-rails-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>ruby</category>
      <category>rails</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I got tired of looking up `rails new` flags, so I built a wizard for it</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Fri, 27 Feb 2026 19:47:14 +0000</pubDate>
      <link>https://dev.to/svyatov/i-got-tired-of-looking-up-rails-new-flags-so-i-built-a-wizard-for-it-1adl</link>
      <guid>https://dev.to/svyatov/i-got-tired-of-looking-up-rails-new-flags-so-i-built-a-wizard-for-it-1adl</guid>
      <description>&lt;p&gt;Every time I start a new Rails project, the same thing happens. I open the terminal, type &lt;code&gt;rails new&lt;/code&gt;, and then... pause. What was the flag for PostgreSQL? Is it &lt;code&gt;--skip-test&lt;/code&gt; or &lt;code&gt;--skip-tests&lt;/code&gt;? Do I want Propshaft or Sprockets? What about Solid Queue — is that a flag now?&lt;/p&gt;

&lt;p&gt;So I open &lt;code&gt;rails new --help&lt;/code&gt;, scroll through a wall of options, and piece together a command I'll forget by next week.&lt;/p&gt;

&lt;p&gt;After doing this too many times, I built &lt;a href="https://github.com/svyatov/create-rails-app" rel="noopener noreferrer"&gt;create-rails-app&lt;/a&gt; — an interactive CLI wizard that walks you through every &lt;code&gt;rails new&lt;/code&gt; option one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Instead of memorizing flags, you answer simple questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[01/31] API only application? (no)  ›  Yes / No
[03/31] Database (postgresql)       ›  sqlite3 / postgresql / mysql / trilogy ...
[04/31] JavaScript approach (importmap) › importmap / bun / esbuild / webpack ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each question shows your last-used choice as the default, so repeat projects take seconds.&lt;/p&gt;

&lt;p&gt;At the end, you see the full &lt;code&gt;rails new&lt;/code&gt; command — review it, then run it or go back and tweak your choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things I find useful
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It remembers your choices.&lt;/strong&gt; Next time you run it, your previous selections are pre-filled. Change one or two things and go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Presets.&lt;/strong&gt; Save a named configuration and reuse it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;create-rails-app myapp &lt;span class="nt"&gt;--preset&lt;/span&gt; api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have presets for "api", "fullstack", and "minimal" — covers 90% of my projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart skip rules.&lt;/strong&gt; Disable Active Record? It won't ask about databases. Choose API mode? It skips CSS and JavaScript framework questions. The wizard stays short and relevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Back navigation.&lt;/strong&gt; Changed your mind three questions in? &lt;code&gt;Ctrl+B&lt;/code&gt; goes back. No need to start over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version-aware.&lt;/strong&gt; It detects your installed Rails version (supports 7.2, 8.0, and 8.1) and only shows options that apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;create-rails-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Run &lt;code&gt;create-rails-app&lt;/code&gt; and follow the prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use a template?
&lt;/h2&gt;

&lt;p&gt;Rails templates are great for adding code &lt;em&gt;after&lt;/em&gt; project creation. But they don't help with the &lt;code&gt;rails new&lt;/code&gt; flags themselves — the database, JS bundler, asset pipeline, which frameworks to skip. That's the part I kept forgetting, and that's what this tool handles.&lt;/p&gt;




&lt;p&gt;If you start new Rails projects more than once a year and find yourself googling the same flags, give it a try. Feedback and contributions welcome on &lt;a href="https://github.com/svyatov/create-rails-app" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>cli</category>
      <category>productivity</category>
    </item>
    <item>
      <title>clsx for Rails views</title>
      <dc:creator>Leonid Svyatov</dc:creator>
      <pubDate>Sun, 03 Mar 2024 19:25:38 +0000</pubDate>
      <link>https://dev.to/svyatov/clsx-for-rails-views-i3</link>
      <guid>https://dev.to/svyatov/clsx-for-rails-views-i3</guid>
      <description>&lt;p&gt;Hey, fellow Rubyists!&lt;/p&gt;

&lt;p&gt;Have you ever found yourself wrestling with conditional class strings in your Rails views? Juggling string interpolation, conditional logic, and array manipulation to apply a class or two conditionally? It's like trying to thread a needle in a hurricane. Well, fret no more! Say hello to &lt;a href="https://github.com/svyatov/clsx-rails" rel="noopener noreferrer"&gt;clsx-rails&lt;/a&gt;, the newest member of the Ruby gems family, here to make your life a tad bit easier.&lt;/p&gt;

&lt;p&gt;Inspired by the simplicity of the &lt;a href="https://github.com/lukeed/clsx" rel="noopener noreferrer"&gt;clsx&lt;/a&gt; npm package, &lt;code&gt;clsx-rails&lt;/code&gt; is your Rails view's new best friend. It's a tiny helper with a mighty purpose: to streamline how you handle CSS classes, making your code cleaner, more readable, and, let's be honest, more elegant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;clsx-rails&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Because we've all been there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'foo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bar'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'baz'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Hello, Rails world!
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It gets the job done, but it's about as graceful as a giraffe on ice skates. With &lt;code&gt;clsx-rails&lt;/code&gt;, you can turn that mess into a one-liner marvel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;clsx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:foo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:bar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;baz: &lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Hello, Rails world!
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Usage Examples
&lt;/h2&gt;

&lt;p&gt;Whether it's a string, a hash, or an array, &lt;code&gt;clsx-rails&lt;/code&gt; handles them all, even nesting them like Russian dolls if that's your jam. It even eliminates any duplicates while at it! And for those who like their code like their coffee—short and strong - there's the &lt;code&gt;cn&lt;/code&gt; alias.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Variadic strings&lt;/span&gt;
&lt;span class="n"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'this'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'is'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'awesome'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; 'this is awesome'&lt;/span&gt;

&lt;span class="c1"&gt;# Hashes with truthy/falsy values&lt;/span&gt;
&lt;span class="n"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;foo: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;bar: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;baz: &lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; 'foo baz' (assuming the condition is true)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Although not advised, it can handle any level of "complexity":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;clsx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;[[[[&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'b'&lt;/span&gt;&lt;span class="p"&gt;]]],&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;a: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;b: &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="mi"&gt;1&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&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="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="sx"&gt;%w[foo bar]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; 'a b 1 2 3 4 foo bar'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can find more details and usage examples in the &lt;a href="https://github.com/svyatov/clsx-rails" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It's Useful
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keeps your views DRY and readable.&lt;/li&gt;
&lt;li&gt;Reduces the mental overhead of handling conditional classes.&lt;/li&gt;
&lt;li&gt;It's like giving your views a neat little toolbelt for CSS class manipulation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, if you're tired of the conditional class string chaos and want to add some syntactic sugar to your Rails views, give &lt;a href="https://github.com/svyatov/clsx-rails" rel="noopener noreferrer"&gt;clsx-rails&lt;/a&gt; a whirl. Your views will thank you, and who knows, you might find yourself with a bit more time for coffee breaks :)&lt;/p&gt;

&lt;p&gt;Happy coding!&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>css</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
