<?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: 이준김</title>
    <description>The latest articles on DEV Community by 이준김 (@textmachine).</description>
    <link>https://dev.to/textmachine</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%2F4088842%2Fb4eef027-5763-407a-95d8-99ed3c865591.png</url>
      <title>DEV Community: 이준김</title>
      <link>https://dev.to/textmachine</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/textmachine"/>
    <language>en</language>
    <item>
      <title>Adaptive PNG filtering made my animations 2.4x bigger</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Mon, 24 Aug 2026 03:43:52 +0000</pubDate>
      <link>https://dev.to/textmachine/adaptive-png-filtering-made-my-animations-24x-bigger-42km</link>
      <guid>https://dev.to/textmachine/adaptive-png-filtering-made-my-animations-24x-bigger-42km</guid>
      <description>&lt;p&gt;I added adaptive per-scanline filtering to an APNG encoder, because that is what&lt;br&gt;
you are supposed to do. PNG lets every row pick one of five filters, the spec&lt;br&gt;
suggests picking the one that minimises the sum of absolute differences, and&lt;br&gt;
every PNG optimiser I have read does exactly that.&lt;/p&gt;

&lt;p&gt;It made seven of eight real animations bigger. The worst went up 2.41x.&lt;/p&gt;

&lt;p&gt;Here is the whole set. Same frames, same zlib level, same encoder; the only&lt;br&gt;
difference is whether each scanline gets a filter chosen for it or is left at&lt;br&gt;
filter None.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;frames&lt;/th&gt;
&lt;th&gt;canvas&lt;/th&gt;
&lt;th&gt;None&lt;/th&gt;
&lt;th&gt;adaptive&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cicada molting&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;320x512&lt;/td&gt;
&lt;td&gt;6142.9 KB&lt;/td&gt;
&lt;td&gt;14786.8 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.41x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zipper&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;320x320&lt;/td&gt;
&lt;td&gt;278.9 KB&lt;/td&gt;
&lt;td&gt;520.0 KB&lt;/td&gt;
&lt;td&gt;1.86x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foucault pendulum&lt;/td&gt;
&lt;td&gt;288&lt;/td&gt;
&lt;td&gt;180x240&lt;/td&gt;
&lt;td&gt;5953.8 KB&lt;/td&gt;
&lt;td&gt;10492.0 KB&lt;/td&gt;
&lt;td&gt;1.76x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Universal joint&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;280x164&lt;/td&gt;
&lt;td&gt;678.1 KB&lt;/td&gt;
&lt;td&gt;1148.0 KB&lt;/td&gt;
&lt;td&gt;1.69x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lockstitch&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;419x500&lt;/td&gt;
&lt;td&gt;197.3 KB&lt;/td&gt;
&lt;td&gt;311.9 KB&lt;/td&gt;
&lt;td&gt;1.58x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gun turret&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;700x600&lt;/td&gt;
&lt;td&gt;1108.6 KB&lt;/td&gt;
&lt;td&gt;1590.2 KB&lt;/td&gt;
&lt;td&gt;1.43x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Muybridge race horse&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;300x200&lt;/td&gt;
&lt;td&gt;687.0 KB&lt;/td&gt;
&lt;td&gt;731.9 KB&lt;/td&gt;
&lt;td&gt;1.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lunar libration&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;640x642&lt;/td&gt;
&lt;td&gt;10939.2 KB&lt;/td&gt;
&lt;td&gt;9129.3 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.83x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight animations from Wikimedia, decoded to full-canvas RGBA and re-encoded as&lt;br&gt;
APNG. One file got smaller. Seven got bigger, and Cicada molting went from&lt;br&gt;
6 MB to 14.5 MB.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three explanations that sound right
&lt;/h2&gt;

&lt;p&gt;The first thing I did was write down why, which in hindsight was the mistake.&lt;br&gt;
The story I wrote into the source comment was this: flat art has whole scanlines&lt;br&gt;
that repeat, filter None leaves those repeats byte-identical so LZ77 can match&lt;br&gt;
one row against another across the image, and mixing filter types per row&lt;br&gt;
rewrites identical rows into different bytes and destroys the long matches.&lt;/p&gt;

&lt;p&gt;It is a good story. It is also wrong, and it took one number to find out. I&lt;br&gt;
measured the fraction of scanlines that are byte-identical to the row above&lt;br&gt;
them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;repeated rows&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lockstitch&lt;/td&gt;
&lt;td&gt;39.1%&lt;/td&gt;
&lt;td&gt;1.58x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gun turret&lt;/td&gt;
&lt;td&gt;20.2%&lt;/td&gt;
&lt;td&gt;1.43x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lunar libration&lt;/td&gt;
&lt;td&gt;11.2%&lt;/td&gt;
&lt;td&gt;0.83x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Universal joint&lt;/td&gt;
&lt;td&gt;4.8%&lt;/td&gt;
&lt;td&gt;1.69x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cicada molting&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.41x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foucault pendulum&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;1.76x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Muybridge race horse&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;1.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zipper&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;1.86x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The worst file in the set has no repeated scanlines at all. Across all 28 pairs&lt;br&gt;
of files, "more repeated rows" predicted "worse adaptive result" in 6 of them —&lt;br&gt;
a rule that explains nothing lands near 14.&lt;/p&gt;

&lt;p&gt;Two more candidates went the same way. Palette size, on the theory that flat art&lt;br&gt;
means few colours: 10 of 28 pairs, and the best and worst files sit at 190 and&lt;br&gt;
183 colours. How much the heuristic switches filter type between adjacent rows,&lt;br&gt;
on the theory that the thrashing is what costs: 14 of 28, which is exactly&lt;br&gt;
chance.&lt;/p&gt;

&lt;p&gt;At that point I had a real result and three dead explanations, which is a&lt;br&gt;
reasonable place to stop guessing and run an experiment.&lt;/p&gt;
&lt;h2&gt;
  
  
  The experiment that settles it
&lt;/h2&gt;

&lt;p&gt;If the damage comes from &lt;em&gt;choosing differently per row&lt;/em&gt;, then holding one filter&lt;br&gt;
constant for the whole image should be fine. If the damage comes from&lt;br&gt;
&lt;em&gt;differencing at all&lt;/em&gt;, then any fixed filter loses too.&lt;/p&gt;

&lt;p&gt;So I forced each of the five filters on every scanline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;None&lt;/th&gt;
&lt;th&gt;Sub&lt;/th&gt;
&lt;th&gt;Up&lt;/th&gt;
&lt;th&gt;Average&lt;/th&gt;
&lt;th&gt;Paeth&lt;/th&gt;
&lt;th&gt;heuristic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cicada molting&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.59x&lt;/td&gt;
&lt;td&gt;1.83x&lt;/td&gt;
&lt;td&gt;2.43x&lt;/td&gt;
&lt;td&gt;2.22x&lt;/td&gt;
&lt;td&gt;2.41x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zipper&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.48x&lt;/td&gt;
&lt;td&gt;1.63x&lt;/td&gt;
&lt;td&gt;2.08x&lt;/td&gt;
&lt;td&gt;1.75x&lt;/td&gt;
&lt;td&gt;1.86x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foucault pendulum&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.39x&lt;/td&gt;
&lt;td&gt;1.72x&lt;/td&gt;
&lt;td&gt;2.28x&lt;/td&gt;
&lt;td&gt;1.74x&lt;/td&gt;
&lt;td&gt;1.76x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Universal joint&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.48x&lt;/td&gt;
&lt;td&gt;1.74x&lt;/td&gt;
&lt;td&gt;2.12x&lt;/td&gt;
&lt;td&gt;1.79x&lt;/td&gt;
&lt;td&gt;1.69x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gun turret&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.42x&lt;/td&gt;
&lt;td&gt;1.37x&lt;/td&gt;
&lt;td&gt;2.09x&lt;/td&gt;
&lt;td&gt;1.46x&lt;/td&gt;
&lt;td&gt;1.43x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Muybridge race horse&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.07x&lt;/td&gt;
&lt;td&gt;1.10x&lt;/td&gt;
&lt;td&gt;1.07x&lt;/td&gt;
&lt;td&gt;1.06x&lt;/td&gt;
&lt;td&gt;1.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lunar libration&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;0.90x&lt;/td&gt;
&lt;td&gt;0.86x&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.83x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.86x&lt;/td&gt;
&lt;td&gt;0.83x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every fixed filter loses on the same seven files, by roughly as much as the&lt;br&gt;
heuristic does. It is not the switching. It is the differencing.&lt;/p&gt;

&lt;p&gt;And look at the last row. On Lunar libration — the one continuous-tone source in&lt;br&gt;
the set — every single differencing filter wins. The textbook advice is not&lt;br&gt;
wrong. It is right about a kind of image, and seven of these eight are the other&lt;br&gt;
kind.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the heuristic cannot see this
&lt;/h2&gt;

&lt;p&gt;Minimum sum of absolute differences scores byte &lt;strong&gt;magnitude&lt;/strong&gt;. DEFLATE pays for&lt;br&gt;
&lt;strong&gt;repetition&lt;/strong&gt;. On these frames those two things point in opposite directions.&lt;/p&gt;

&lt;p&gt;A run of identical pixels under filter None is a run of identical bytes, which&lt;br&gt;
is the single thing LZ77 is best at. Under Sub it becomes a run of zeros, which&lt;br&gt;
also compresses, but the moment the run ends the boundary value depends on&lt;br&gt;
whatever came before, and across an image made of flat regions with hard edges&lt;br&gt;
you end up with many small, &lt;em&gt;varying&lt;/em&gt; values instead of a few long, &lt;em&gt;identical&lt;/em&gt;&lt;br&gt;
runs. Small varying bytes win on sum-of-absolute-differences. Long identical&lt;br&gt;
runs win on file size.&lt;/p&gt;

&lt;p&gt;The heuristic never finds out, because it is not measuring file size. Its&lt;br&gt;
preference is visible in how often it leaves a row alone:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows the heuristic assigned filter None: 0.0% to 0.2%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Zero to two rows in a thousand, on files where keeping every row at None is&lt;br&gt;
2.4x smaller. The rule is not slightly miscalibrated. It essentially never&lt;br&gt;
selects the winning option, because the winning option almost never has the&lt;br&gt;
lowest sum of absolute differences.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One extra compression per animation. Encode frame 0 both ways, keep the&lt;br&gt;
strategy that produced fewer bytes, use it for every frame:&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;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frames&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;adaptive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&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="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
  &lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&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="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not per frame. The frames of one animation are the same kind of picture, so the&lt;br&gt;
winner on the first is the winner throughout, and paying double on every frame&lt;br&gt;
to re-confirm that is the expensive way to learn nothing. Cost: one additional&lt;br&gt;
compress of one frame.&lt;/p&gt;

&lt;p&gt;This also keeps the win where the win is real. Lunar libration still gets its&lt;br&gt;
0.83x, because the probe finds it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug in the measurement, which is the part I would want to read
&lt;/h2&gt;

&lt;p&gt;The forced-filter table above did not work the first time. It came out like&lt;br&gt;
this:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;every row forced to one filter:
  None 1.43x  Sub 1.43x  Up 1.43x  Average 1.43x  Paeth 1.43x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Five identical numbers. It would have been easy to squint at that and write&lt;br&gt;
something about how the filter choice barely matters — except that the first&lt;br&gt;
column cannot be 1.43x. Forcing filter None &lt;strong&gt;is&lt;/strong&gt; the None encoder. That column&lt;br&gt;
is 1.00x by definition, and any other value means the measurement is not&lt;br&gt;
measuring what it says.&lt;/p&gt;

&lt;p&gt;The cause: the harness builds each variant by copying the encoder to a temp file&lt;br&gt;
with one line replaced, and all five forced variants were written to the same&lt;br&gt;
filename. Node caches modules by URL, so the second import never sees the new&lt;br&gt;
bytes. Every column was the same build.&lt;/p&gt;

&lt;p&gt;The fix was a unique filename. The lesson was the assertion that now runs before&lt;br&gt;
anything is printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;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;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.001&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`forcing did not take: forced-None is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x of None`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A measurement harness should contain at least one case whose answer you already&lt;br&gt;
know, and it should refuse to report anything if that case comes out wrong. This&lt;br&gt;
one produced a plausible, publishable, completely fictitious result until it had&lt;br&gt;
one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;Three things, in order of how much they cost me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A mechanism you have not measured is a guess with a diagram.&lt;/strong&gt; I wrote the
LZ77-across-identical-scanlines explanation into a source comment as fact.
The worst file in the set has 0.0% identical scanlines. It sat there being
confidently wrong until something made me check it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When two explanations disagree, force each one and see.&lt;/strong&gt; Three
correlational metrics gave me nothing. One experiment — hold the filter
constant — answered it in a single run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimisation folklore is usually true about a specific input distribution&lt;/strong&gt;
and silent about which one. Adaptive PNG filtering is right for photographs
and wrong for palette art, and nothing in the advice says so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The encoder runs in the browser, in the GIF tools on&lt;br&gt;
&lt;a href="https://imagemachinery.net" rel="noopener noreferrer"&gt;Image Machine&lt;/a&gt; — the measurements above come from&lt;br&gt;
the &lt;a href="https://imagemachinery.net/en/tools/gif-to-apng" rel="noopener noreferrer"&gt;GIF to APNG converter&lt;/a&gt;,&lt;br&gt;
where a 6 MB upload coming back as 14.5 MB is the kind of thing a user notices&lt;br&gt;
immediately. Nothing is uploaded; the whole pipeline is client side, which is&lt;br&gt;
also why the encoder's own efficiency is the only lever there is.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>webperf</category>
    </item>
    <item>
      <title>A GIF re-encode made the file 4.5x bigger. Interframe differencing fixed it.</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Sun, 23 Aug 2026 19:42:38 +0000</pubDate>
      <link>https://dev.to/textmachine/a-gif-re-encode-made-the-file-45x-bigger-interframe-differencing-fixed-it-5gnb</link>
      <guid>https://dev.to/textmachine/a-gif-re-encode-made-the-file-45x-bigger-interframe-differencing-fixed-it-5gnb</guid>
      <description>&lt;p&gt;Cropping a GIF should not make it bigger. Ours did, and not by a little: a&lt;br&gt;
288-frame Foucault pendulum went from 1074 KB in to 4881 KB out, and a 70-frame&lt;br&gt;
gun turret went 116 KB to 1232 KB. That is 455% and 1062%. The same thing&lt;br&gt;
happened with no crop at all, which is the detail that gives the cause away.&lt;/p&gt;

&lt;p&gt;If a pure re-encode inflates the file, the encoder is not losing to the&lt;br&gt;
compressor. It is throwing away something the source already had.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it was throwing away
&lt;/h2&gt;

&lt;p&gt;GIF stores an animation as a sequence of image blocks, and each block carries a&lt;br&gt;
disposal method and its own local palette. Nothing requires a block to cover the&lt;br&gt;
whole canvas or to be a complete picture. A well-optimised animated GIF writes&lt;br&gt;
frame 0 in full and then writes every later frame as &lt;em&gt;only what changed&lt;/em&gt;,&lt;br&gt;
leaving the rest transparent and setting disposal to "leave the previous frame&lt;br&gt;
in place". For the pendulum, most of the canvas is a static rig and a dark&lt;br&gt;
background; the swinging bob is a few percent of the pixels. The source spent&lt;br&gt;
its bytes on the bob.&lt;/p&gt;

&lt;p&gt;Our encoder wrote every frame as a full-canvas, fully opaque keyframe. Each&lt;br&gt;
frame was individually correct and the animation played correctly, so nothing&lt;br&gt;
looked wrong. It just re-paid the cost of the static background 287 more times&lt;br&gt;
than it needed to.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;For an opaque source, frames after the first are written as the previous frame&lt;br&gt;
plus the changed pixels. Unchanged pixels get the transparent palette index,&lt;br&gt;
and the frame's disposal is set to 1, "do not dispose". The decoder composites&lt;br&gt;
it over what is already on the canvas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;changedPixels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prev&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&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;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;changed&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;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&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;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;p&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;changed&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;changed&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;RGB only, no alpha compare: this path runs on opaque sources, where every alpha&lt;br&gt;
is already 255. Sources that carry real transparency keep the old keyframe path,&lt;br&gt;
because the transparent index is then already spoken for.&lt;/p&gt;

&lt;p&gt;One constraint shaped the implementation. We use &lt;a href="https://github.com/mattdesl/gifenc" rel="noopener noreferrer"&gt;gifenc&lt;/a&gt;, which&lt;br&gt;
hard-codes the image descriptor to x=0, y=0, so a frame cannot be written as a&lt;br&gt;
sub-rectangle at an offset the way most GIF encoders do it. Every frame stays&lt;br&gt;
full-canvas, and the saving comes from LZW collapsing the long runs of the&lt;br&gt;
repeated transparent index instead. That reaches the same redundancy. It just&lt;br&gt;
spends a little more CPU to get there.&lt;/p&gt;
&lt;h2&gt;
  
  
  The heuristic that did not work
&lt;/h2&gt;

&lt;p&gt;The obvious optimisation is a threshold: if more than some fraction of the&lt;br&gt;
frame changed, a delta is not worth it, so write a keyframe. We implemented it&lt;br&gt;
and then deleted it, because the measurements did not cooperate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;frame-to-frame change&lt;/th&gt;
&lt;th&gt;result vs. keyframes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;13% of pixels&lt;/td&gt;
&lt;td&gt;74% of the size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26% of pixels&lt;/td&gt;
&lt;td&gt;112% of the size&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The file that changed &lt;em&gt;twice as much&lt;/em&gt; was the one that should have used deltas,&lt;br&gt;
and the file that changed less was the one that should not have. What decides it&lt;br&gt;
is not how many pixels moved but how &lt;em&gt;scattered&lt;/em&gt; the movement is, because LZW&lt;br&gt;
pays for runs, not for pixels. A count of changed pixels cannot see that, and&lt;br&gt;
neither can any threshold built on one.&lt;/p&gt;

&lt;p&gt;So the encoder stops guessing. For each file it takes three sample frames,&lt;br&gt;
encodes each one both ways, and keeps whichever mode won. Both candidates are&lt;br&gt;
appended after the identical frame 0, so the header, the global palette and the&lt;br&gt;
loop block cancel out and what is compared is that frame's cost alone.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap worth knowing about
&lt;/h2&gt;

&lt;p&gt;The changed pixels have to be packed into a buffer of their own before being&lt;br&gt;
quantized, and that buffer must be a fresh allocation, never a &lt;code&gt;subarray&lt;/code&gt; view:&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8ClampedArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gifenc's &lt;code&gt;quantize()&lt;/code&gt; and &lt;code&gt;applyPalette()&lt;/code&gt; both do &lt;code&gt;new Uint32Array(rgba.buffer)&lt;/code&gt;.&lt;br&gt;
On a view, &lt;code&gt;.buffer&lt;/code&gt; is the &lt;em&gt;whole&lt;/em&gt; underlying buffer, not the slice. Both&lt;br&gt;
functions would read far past the region you meant to hand them and build the&lt;br&gt;
palette from the wrong pixels, silently, with no error and a plausible-looking&lt;br&gt;
result. This is a general hazard with any typed-array API that reaches for&lt;br&gt;
&lt;code&gt;.buffer&lt;/code&gt;, and it is worth checking for before you spend an afternoon on a&lt;br&gt;
palette that is subtly wrong.&lt;/p&gt;

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

&lt;p&gt;Ten animated GIFs from Wikimedia Commons, including the pendulum and the turret&lt;br&gt;
above, a Muybridge race horse, a cicada molting, lunar libration, a universal&lt;br&gt;
joint and a constant-velocity joint.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Output is 15% to 100% of the previous encoder's, median 90%. Zero files got
bigger.&lt;/li&gt;
&lt;li&gt;The two worst cases are transformed: turret 1232 KB to 184 KB, pendulum
4881 KB to 1292 KB.&lt;/li&gt;
&lt;li&gt;All ten are now smaller than the source they came from, in a 17% to 79% range.&lt;/li&gt;
&lt;li&gt;Colour error is equal or better on all ten. Frame count, dimensions, per-frame
delay, loop flag and transparency are all preserved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The median matters as much as the wins: half of these files barely moved,&lt;br&gt;
because half of them do not have a large static region to exploit. This is a&lt;br&gt;
fix for a failure mode, not a general compression gain, and it is worth being&lt;br&gt;
precise about which one you are shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it against something that is not us
&lt;/h2&gt;

&lt;p&gt;A round-trip through our own decoder only proves the encoder and the decoder&lt;br&gt;
agree with each other. So the test builds a synthetic animation of under 255&lt;br&gt;
colours, encodes it, and decodes the result with &lt;strong&gt;Pillow&lt;/strong&gt;, which implements&lt;br&gt;
GIF disposal independently of anything we wrote. All 12 frames matched&lt;br&gt;
pixel-for-pixel, and the delays matched too.&lt;/p&gt;

&lt;p&gt;If you write an encoder for a format with compositing semantics, find a second&lt;br&gt;
implementation and make it the judge. Disposal methods are exactly the kind of&lt;br&gt;
thing two codebases can agree to get wrong together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Encoding takes longer, because deciding the mode means encoding three frames&lt;br&gt;
twice. Worst measured case was +140 ms on a 22-frame file. On large files, where&lt;br&gt;
the absolute time is what you would notice, it stayed within +7%.&lt;/p&gt;

&lt;p&gt;The encoder runs in the browser, in the GIF tools on&lt;br&gt;
&lt;a href="https://imagemachinery.net" rel="noopener noreferrer"&gt;Image Machine&lt;/a&gt; — the measurements above come from&lt;br&gt;
the &lt;a href="https://imagemachinery.net/en/tools/crop-gif" rel="noopener noreferrer"&gt;GIF cropper&lt;/a&gt;, where the&lt;br&gt;
inflation showed up first. Nothing is uploaded; the whole pipeline is client&lt;br&gt;
side, which is also why the encoder's own efficiency is the only lever there is.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Search Console found one 502. All 11 locales were broken, and no test could have caught it.</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Sat, 22 Aug 2026 16:16:15 +0000</pubDate>
      <link>https://dev.to/textmachine/search-console-found-one-502-all-11-locales-were-broken-and-no-test-could-have-caught-it-56ic</link>
      <guid>https://dev.to/textmachine/search-console-found-one-502-all-11-locales-were-broken-and-no-test-could-have-caught-it-56ic</guid>
      <description>&lt;p&gt;Search Console showed one URL in the "Server error (5xx)" bucket. One. On a site&lt;br&gt;
with about 1,700 pages, that reads like noise, and I nearly left it.&lt;/p&gt;

&lt;p&gt;The URL was &lt;code&gt;/th/opengraph-image&lt;/code&gt; — the Thai locale's OpenGraph image. So I&lt;br&gt;
checked the other ten locales before closing the tab.&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;en/opengraph-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;span class="na"&gt;ko/opengraph-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;span class="na"&gt;th/opengraph-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;span class="na"&gt;ja/opengraph-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;span class="na"&gt;de/opengraph-image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single one. The route had never worked, on any locale, and Search Console&lt;br&gt;
knew about exactly one of them because it had only ever tried one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why nothing told me
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising, because it generalises past OG images.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/[locale]/opengraph-image.tsx&lt;/code&gt; is a Next.js file convention. You do not&lt;br&gt;
import it, you do not link to it, and no page in your app renders it. Its entire&lt;br&gt;
job is to make Next inject a &lt;code&gt;&amp;lt;meta property="og:image"&amp;gt;&lt;/code&gt; tag pointing at a&lt;br&gt;
route it generates for you.&lt;/p&gt;

&lt;p&gt;So consider what could have caught this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not the browser.&lt;/strong&gt; No page requests the image. You can click through the
whole site and see nothing wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not the build.&lt;/strong&gt; It compiles fine. The failure is at render time, per
request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not the tests.&lt;/strong&gt; Nobody writes a test for an image nobody imports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not the deploy checks.&lt;/strong&gt; Mine fetch pages and grep for text. This route
isn't a page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not a user report.&lt;/strong&gt; The failure surface is a social preview card being
blank on someone else's site.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only witness was a crawler, and only because it stumbled across one locale.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug is a child count
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next/og&lt;/code&gt; renders with &lt;a href="https://github.com/vercel/satori" rel="noopener noreferrer"&gt;Satori&lt;/a&gt;, which&lt;br&gt;
implements a subset of CSS. One of its rules:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Expected &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; to have explicit "display: flex" or "display: none" if it has&lt;br&gt;
more than one child node.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now look at the line that broke it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontWeight&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="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;TOOL_COUNT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;+ Free Online Text Tools
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a human and it is one sentence. Read it as JSX and it is &lt;strong&gt;two&lt;br&gt;
children&lt;/strong&gt;: the expression container &lt;code&gt;{TOOL_COUNT}&lt;/code&gt;, and the text node&lt;br&gt;
&lt;code&gt;"+ Free Online Text Tools"&lt;/code&gt;. The div has no &lt;code&gt;display&lt;/code&gt;, so Satori throws.&lt;/p&gt;

&lt;p&gt;The rest of the file was fine. Every other div either declared &lt;code&gt;display: flex&lt;/code&gt;&lt;br&gt;
or genuinely had one child. This one looked exactly like the others.&lt;/p&gt;
&lt;h2&gt;
  
  
  How a thrown renderer becomes a 502
&lt;/h2&gt;

&lt;p&gt;Worth following, because the error you see is three layers away from the cause.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ImageResponse&lt;/code&gt; streams. By the time Satori throws, Next has already sent&lt;br&gt;
response headers and started the body, so it cannot turn the failure into a 500&lt;br&gt;
page — it closes the connection. Node logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: failed to pipe response
  [cause]: Error: Expected &amp;lt;div&amp;gt; to have explicit "display: flex" ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx, waiting upstream, logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;upstream prematurely closed connection while reading response header from upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and returns &lt;strong&gt;502&lt;/strong&gt; to the client. So the outward symptom is a gateway error,&lt;br&gt;
which is the vocabulary of infrastructure problems: you go and look at memory,&lt;br&gt;
at the process, at the proxy config. The actual cause is a JSX child count.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix, and the one I did not choose
&lt;/h2&gt;

&lt;p&gt;The error message asks for &lt;code&gt;display: flex&lt;/code&gt;, and that works. I did something&lt;br&gt;
smaller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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;TOOL_COUNT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;+ Free Online Text Tools`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One template literal is one text node, so the multi-child rule never applies.&lt;br&gt;
The reason to prefer it: adding &lt;code&gt;display: flex&lt;/code&gt; to a text div also makes the&lt;br&gt;
text a flex item, which changes how it wraps against that &lt;code&gt;maxWidth: 1000&lt;/code&gt;.&lt;br&gt;
The template literal fixes the crash and changes nothing about the layout.&lt;/p&gt;

&lt;p&gt;Result across all eleven locales, in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;200  image/png  244560 bytes   (1200x630)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three things I would do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Curl the route after touching it.&lt;/strong&gt; It is the only check that exists.&lt;br&gt;
Nothing in the normal loop covers a file that nothing imports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Know which pages actually depend on it.&lt;/strong&gt; This was the part that turned a&lt;br&gt;
curiosity into a real bug. Pages that set &lt;code&gt;openGraph.images&lt;/code&gt; explicitly override&lt;br&gt;
the file convention and were fine. Pages that do not — my privacy policy and&lt;br&gt;
terms pages, in all eleven locales — inherited it, and were publishing a 502 as&lt;br&gt;
their &lt;code&gt;og:image&lt;/code&gt;. I would not have guessed that split without checking:&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;-s&lt;/span&gt; https://example.com/en/privacy-policy &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;meta property="og:image" content="[^"]*"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gets you the tag. To see the card a crawler would actually build from&lt;br&gt;
it, an &lt;a href="https://textmachine.org/en/seo-tools/open-graph-previewer" rel="noopener noreferrer"&gt;Open Graph previewer&lt;/a&gt;&lt;br&gt;
will fetch the page and render the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Treat a count of one as a sample, not a total.&lt;/strong&gt; Search Console reports&lt;br&gt;
what it happened to crawl. One 5xx URL on a multi-locale route means "at least&lt;br&gt;
one", and the cheapest possible follow-up — a for-loop over the locale list —&lt;br&gt;
turned 1 into 11.&lt;/p&gt;




&lt;p&gt;This happened on &lt;a href="https://textmachine.org" rel="noopener noreferrer"&gt;textmachine.org&lt;/a&gt;, a set of text&lt;br&gt;
tools that do their work in the browser.&lt;/p&gt;

&lt;p&gt;One last detail I find genuinely funny. There is a second OG route in the same&lt;br&gt;
codebase, &lt;code&gt;app/og/route.tsx&lt;/code&gt;, which has always worked. I counted its divs: 15,&lt;br&gt;
of which 13 declare &lt;code&gt;display: flex&lt;/code&gt;. The two that do not are&lt;br&gt;
&lt;code&gt;&amp;lt;div&amp;gt;TEXT MACHINE&amp;lt;/div&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;div&amp;gt;textmachine.org&amp;lt;/div&amp;gt;&lt;/code&gt; — single text nodes,&lt;br&gt;
where the rule does not apply. That file follows Satori's constraint exactly,&lt;br&gt;
including knowing when it is not needed.&lt;/p&gt;

&lt;p&gt;So the knowledge existed in the repository. It just did not exist in the second&lt;br&gt;
file, because nothing carries a rule from one file to another except the person&lt;br&gt;
writing it — and a rule you can only violate in a route nobody renders is a rule&lt;br&gt;
you will violate eventually.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>seo</category>
    </item>
    <item>
      <title>Compressing an image to an exact file size, in the browser</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Sat, 22 Aug 2026 13:26:47 +0000</pubDate>
      <link>https://dev.to/textmachine/compressing-an-image-to-an-exact-file-size-in-the-browser-12ha</link>
      <guid>https://dev.to/textmachine/compressing-an-image-to-an-exact-file-size-in-the-browser-12ha</guid>
      <description>&lt;p&gt;"Make this photo under 100 KB" is one of the most common things people need&lt;br&gt;
from an image tool, and one of the few things almost no image editor exposes&lt;br&gt;
directly. Photoshop gives you a quality slider. &lt;code&gt;cwebp&lt;/code&gt; gives you &lt;code&gt;-q&lt;/code&gt;. The&lt;br&gt;
canvas API gives you a float between 0 and 1. None of them takes bytes.&lt;/p&gt;

&lt;p&gt;The reason is that the mapping you want does not exist in the encoder. You ask&lt;br&gt;
for a file size; the encoder only accepts a quality. So you have to search.&lt;/p&gt;

&lt;p&gt;This is a writeup of how that search is implemented in a browser-only image&lt;br&gt;
tool, including the parts that are less obvious than "do a binary search" —&lt;br&gt;
what happens when the assumption behind binary search is false, why PNG needs a&lt;br&gt;
completely different search space, and why the whole thing has to leave the&lt;br&gt;
main thread.&lt;/p&gt;
&lt;h2&gt;
  
  
  The naive version, and why it is not good enough
&lt;/h2&gt;

&lt;p&gt;The obvious loop:&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;let&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;target&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;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;5&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 works. It is also up to 18 encodes, and every encode is a full pass over&lt;br&gt;
every pixel. On a 12 MP phone photo that is a real amount of work, and the user&lt;br&gt;
is watching a spinner the whole time.&lt;/p&gt;

&lt;p&gt;Worse, it is biased. Stepping down from 90 finds the first quality that fits,&lt;br&gt;
not the &lt;em&gt;best&lt;/em&gt; quality that fits. If the target is generous you stop at 90 and&lt;br&gt;
hand back a file far under the budget — which sounds harmless until you realise&lt;br&gt;
the user asked for "under 100 KB" precisely because they want to spend all&lt;br&gt;
100 KB, not 30.&lt;/p&gt;
&lt;h2&gt;
  
  
  Binary search over the quality space
&lt;/h2&gt;

&lt;p&gt;The real requirement is: &lt;strong&gt;find the highest quality whose output fits under the&lt;br&gt;
target.&lt;/strong&gt; That is a textbook binary search, provided one assumption holds (more&lt;br&gt;
on that in a moment).&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;let&lt;/span&gt; &lt;span class="nx"&gt;low&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;high&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mid&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt;&lt;span class="p"&gt;)&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;encodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mid&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;smallest&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// fits — try to spend more&lt;/span&gt;
    &lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&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="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&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;// too big — back off&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;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight iterations is not an arbitrary cap. &lt;code&gt;log2(100) ≈ 6.64&lt;/code&gt;, so seven&lt;br&gt;
iterations are enough to collapse a 100-value range to a single candidate;&lt;br&gt;
eight leaves a margin and still bounds the worst case at well under half the&lt;br&gt;
linear scan. In exchange you get the &lt;em&gt;best&lt;/em&gt; fitting quality rather than the&lt;br&gt;
first one you tripped over.&lt;/p&gt;

&lt;p&gt;Two details in that loop are worth more than the loop itself.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;best ?? smallest&lt;/code&gt; — the failure path is a real path
&lt;/h3&gt;

&lt;p&gt;If the target is smaller than the output at quality 1, nothing fits. &lt;code&gt;best&lt;/code&gt;&lt;br&gt;
stays &lt;code&gt;null&lt;/code&gt; and a naive implementation throws.&lt;/p&gt;

&lt;p&gt;That is the wrong behaviour, because the user's intent is still perfectly&lt;br&gt;
clear. Someone who asks for a 5 KB thumbnail from a 20 MP photo is not helped&lt;br&gt;
by an error dialog; they are helped by the smallest file the encoder can&lt;br&gt;
produce, plus the honest information that it did not reach 5 KB.&lt;/p&gt;

&lt;p&gt;So the loop tracks &lt;code&gt;smallest&lt;/code&gt; on every iteration, independently of whether it&lt;br&gt;
fits. Failing to hit the target degrades to "here is the closest we got"&lt;br&gt;
instead of "no."&lt;/p&gt;
&lt;h3&gt;
  
  
  Monotonicity is an assumption, not a fact
&lt;/h3&gt;

&lt;p&gt;Binary search over quality assumes that a higher quality always yields a larger&lt;br&gt;
file. For JPEG that is &lt;em&gt;nearly&lt;/em&gt; always true, and it is true enough that the&lt;br&gt;
search converges on real photographs.&lt;/p&gt;

&lt;p&gt;It is not a law. Quantization table changes across quality levels can, on&lt;br&gt;
synthetic or heavily-banded images, produce a step where a higher quality&lt;br&gt;
compresses marginally smaller. When that happens the search can walk into the&lt;br&gt;
wrong half.&lt;/p&gt;

&lt;p&gt;Tracking &lt;code&gt;smallest&lt;/code&gt; separately covers this too, which is a nice property: the&lt;br&gt;
same three lines that handle "target impossible" also handle "monotonicity&lt;br&gt;
violated." You do not need to detect the violation, you just need to stop&lt;br&gt;
trusting the search to have seen the smallest output.&lt;/p&gt;
&lt;h2&gt;
  
  
  PNG is a different problem entirely
&lt;/h2&gt;

&lt;p&gt;Everything above assumes a quality parameter. PNG does not have one. PNG is&lt;br&gt;
lossless; its compression level affects speed far more than size, and there is&lt;br&gt;
no dial that trades fidelity for bytes.&lt;/p&gt;

&lt;p&gt;To make a PNG meaningfully smaller you have to reduce the &lt;em&gt;palette&lt;/em&gt;. That is&lt;br&gt;
what "lossy PNG" tools like pngquant do: quantize 16.7 million possible colors&lt;br&gt;
down to N, then let DEFLATE do far better on the smaller symbol set.&lt;/p&gt;

&lt;p&gt;So the search space changes from quality to color count:&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;cnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lossless&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;                                                  &lt;span class="c1"&gt;// 0 = full color&lt;/span&gt;
  &lt;span class="p"&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&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;2&lt;/span&gt;&lt;span class="p"&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;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;quality&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="nx"&gt;UPNG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cnum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary search is structurally identical — it is still "find the highest&lt;br&gt;
value that fits" — but the value being searched is a palette size from 2 to&lt;br&gt;
256, and the perceptual cost of getting it wrong is completely different.&lt;br&gt;
Dropping JPEG quality softens detail. Dropping PNG palette size produces&lt;br&gt;
banding in gradients, which is far more visible and far less forgiving.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that actually breaks the page
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;canvas.toBlob()&lt;/code&gt; is asynchronous. It hands you a callback rather than&lt;br&gt;
occupying the JS thread for the duration of the encode, so eight of them in a&lt;br&gt;
row is eight callbacks and the page keeps painting between them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UPNG.encode()&lt;/code&gt; is synchronous. It is a pako-backed DEFLATE running in&lt;br&gt;
JavaScript, and on a large image it can occupy the main thread for several&lt;br&gt;
seconds. Per call. And the target-size path calls it &lt;strong&gt;eight times.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is not a slow spinner, that is a frozen tab. No spinner animates, no&lt;br&gt;
button responds, and on a phone the browser may offer to kill the page.&lt;/p&gt;

&lt;p&gt;The fix is a Web Worker, but the interesting part is what that forces on the&lt;br&gt;
code structure. A worker has no DOM. It has no &lt;code&gt;document&lt;/code&gt;, no&lt;br&gt;
&lt;code&gt;HTMLCanvasElement&lt;/code&gt;, no &lt;code&gt;getImageData&lt;/code&gt;. So the encode logic cannot live in the&lt;br&gt;
same module as the canvas code that produced the pixels.&lt;/p&gt;

&lt;p&gt;The split ends up being:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;compressEngine.ts   ← touches canvas/DOM, runs on the main thread
pngEncodeCore.ts    ← pure: (RGBA buffer, w, h, targetBytes) → ArrayBuffer
pngEncode.worker.ts ← thin wrapper that calls the core off-thread
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pngEncodeCore&lt;/code&gt; takes raw RGBA bytes — exactly what&lt;br&gt;
&lt;code&gt;getImageData().data.buffer&lt;/code&gt; gives you — and returns an &lt;code&gt;ArrayBuffer&lt;/code&gt;. It&lt;br&gt;
imports nothing from the DOM, which is what makes it importable from both&lt;br&gt;
sides. The binary search lives in the core, so the main thread and the worker&lt;br&gt;
cannot drift apart in behaviour; there is one implementation, called two ways.&lt;/p&gt;

&lt;p&gt;This is a generally useful shape for anything CPU-heavy in a browser:&lt;br&gt;
&lt;strong&gt;separate the pure transform from the DOM plumbing, and the worker boundary&lt;br&gt;
becomes a detail rather than a rewrite.&lt;/strong&gt; The reason it is worth doing&lt;br&gt;
deliberately is that the DOM dependency tends to creep in — one&lt;br&gt;
&lt;code&gt;document.createElement("canvas")&lt;/code&gt; inside the transform and the module can&lt;br&gt;
never be imported by a worker again.&lt;/p&gt;
&lt;h2&gt;
  
  
  Transferables, and why this code deliberately does not use them
&lt;/h2&gt;

&lt;p&gt;One more thing that matters at photo resolutions. A 12 MP image is&lt;br&gt;
&lt;code&gt;4032 × 3024 × 4&lt;/code&gt; bytes of RGBA, about 48 MB. Passing that to a worker with a&lt;br&gt;
structured clone copies all 48 MB.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ArrayBuffer&lt;/code&gt; is transferable, so the textbook fix is to post it with a&lt;br&gt;
transfer list:&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="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That transfers ownership instead of copying, and the cost of handing the&lt;br&gt;
pixels over drops to approximately nothing.&lt;/p&gt;

&lt;p&gt;This implementation does not do that, on purpose, and the reason is worth more&lt;br&gt;
than the optimization.&lt;/p&gt;

&lt;p&gt;A worker can fail to exist. &lt;code&gt;new Worker(new URL(...), { type: "module" })&lt;/code&gt;&lt;br&gt;
throws in environments where module workers are unavailable or the bundler&lt;br&gt;
emitted something the browser will not load, and even when construction&lt;br&gt;
succeeds the worker can fire an &lt;code&gt;error&lt;/code&gt; event before it does any useful work.&lt;br&gt;
Both cases have to degrade to encoding on the main thread — slower and&lt;br&gt;
blocking, but correct — rather than failing the user's compression.&lt;/p&gt;

&lt;p&gt;Transferring detaches the buffer on the sending side. It becomes zero-length.&lt;br&gt;
So the moment you add that transfer list, the main-thread fallback has nothing&lt;br&gt;
left to encode, and a worker failure stops being a performance regression and&lt;br&gt;
starts being a broken result.&lt;/p&gt;

&lt;p&gt;There is a second, sharper reason here specifically. The buffer being sent is&lt;br&gt;
&lt;code&gt;imageData.data.buffer&lt;/code&gt; — it belongs to a live &lt;code&gt;ImageData&lt;/code&gt; object obtained from&lt;br&gt;
the canvas. Detaching it does not just cost you the fallback, it reaches back&lt;br&gt;
into an object the rest of the function may still touch.&lt;/p&gt;

&lt;p&gt;Both are avoidable, of course: encode a copy, or re-read the pixels from the&lt;br&gt;
canvas in the fallback path. But &lt;code&gt;getImageData&lt;/code&gt; on a 12 MP canvas is not free&lt;br&gt;
either, and you would be paying it on the failure path — which is the path with&lt;br&gt;
the least budget to spare, because it is about to run a multi-second&lt;br&gt;
synchronous encode on the main thread.&lt;/p&gt;

&lt;p&gt;So the trade taken is: &lt;strong&gt;pay one structured clone every time, to guarantee the&lt;br&gt;
fallback always has valid pixels.&lt;/strong&gt; The copy is a fixed, predictable cost on&lt;br&gt;
the happy path. The alternative is a rare, hard-to-reproduce failure that&lt;br&gt;
produces a wrong answer instead of a slow one.&lt;/p&gt;

&lt;p&gt;That is generally the right way round. It is also the kind of decision worth&lt;br&gt;
writing into a comment at the call site, because the next person to read&lt;br&gt;
&lt;code&gt;postMessage&lt;/code&gt; without a transfer list will assume it is an oversight and&lt;br&gt;
"fix" it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys, and what it does not
&lt;/h2&gt;

&lt;p&gt;The result is a tool where "compress to 100 KB" means what it says, runs&lt;br&gt;
entirely on the user's machine, and does not freeze the tab while it works.&lt;br&gt;
There is no upload, so there is no upload wait and no server-side size limit.&lt;/p&gt;

&lt;p&gt;What it does not buy is magic. If a target is unreachable at the source&lt;br&gt;
resolution, no search finds it, and the honest answer is to say so and offer&lt;br&gt;
the closest result — or to resize first, which changes the pixel count and&lt;br&gt;
therefore the whole curve. A search over quality cannot fix a budget that only&lt;br&gt;
a smaller image can meet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The implementation described here is from&lt;br&gt;
&lt;a href="https://imagemachinery.net" rel="noopener noreferrer"&gt;Image Machine&lt;/a&gt;, a set of browser-side image tools:&lt;br&gt;
compression to a target size, format conversion, resizing and effects. It has&lt;br&gt;
no upload endpoint, so everything above runs on the machine you are sitting at.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>You don't search for the key, you search for English: how cipher auto-solvers actually work</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Fri, 21 Aug 2026 21:53:08 +0000</pubDate>
      <link>https://dev.to/textmachine/you-dont-search-for-the-key-you-search-for-english-how-cipher-auto-solvers-actually-work-42lc</link>
      <guid>https://dev.to/textmachine/you-dont-search-for-the-key-you-search-for-english-how-cipher-auto-solvers-actually-work-42lc</guid>
      <description>&lt;p&gt;You've figured out &lt;em&gt;which&lt;/em&gt; cipher you're staring at — say a monoalphabetic cryptogram, or a Vigenère — but you don't have the key. No keyword, no shift, no crib. Manually, this is where people grind for hours. Automatically, a good solver recovers it in about a second. Here's how that actually works, so the tool isn't a black box.&lt;/p&gt;

&lt;p&gt;The whole game rests on one idea: &lt;strong&gt;you don't search for the key, you search for English.&lt;/strong&gt; A wrong key produces gibberish; the right key produces text that looks like a real language. So if you can &lt;em&gt;score&lt;/em&gt; how English-like a candidate decryption is, breaking the cipher becomes an optimization problem — find the key that maximizes the score. Everything below is a variation on that theme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring function is the secret, and single letters aren't enough
&lt;/h2&gt;

&lt;p&gt;The naive score is letter frequency: real English is ~12.7% E, ~9% T, and so on, so reward decryptions whose letter distribution matches. This is &lt;strong&gt;too weak&lt;/strong&gt;. A decryption that's 95% correct can score &lt;em&gt;as well as or better than&lt;/em&gt; the true plaintext on single-letter counts alone, because shuffling a few letters barely moves the histogram. The search then happily settles on a near-miss garble and calls it done.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;n-grams&lt;/strong&gt; — scoring &lt;em&gt;sequences&lt;/em&gt; of letters, not single ones. English is far richer in some letter-pairs and triples (TH, HE, IN, ER; THE, AND, ING) than in others (QZ, JX, VKZ). Any decoding error injects rare, low-probability pairs and triples, which a bigram or trigram score punishes hard. So the fitness function is the &lt;strong&gt;sum of log-probabilities of every trigram&lt;/strong&gt; in the candidate plaintext, using a frequency table built from a large English corpus. Truth scores strictly higher than any near-miss, which is exactly what you need to climb toward.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A useful diagnostic if you ever build one of these: if your solver lands on garbage, check whether &lt;code&gt;score(true plaintext) &amp;gt; score(found)&lt;/code&gt;. If truth scores &lt;em&gt;higher&lt;/em&gt;, your fitness function is fine and your &lt;strong&gt;search&lt;/strong&gt; is stuck — don't tune the scorer, fix the optimizer (next section). If truth scores &lt;em&gt;lower&lt;/em&gt;, the scorer itself is too weak (you're probably on single letters — go to trigrams).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cracking a monoalphabetic substitution (cryptogram)
&lt;/h2&gt;

&lt;p&gt;A simple substitution maps each letter to another, fixed for the whole message. There are 26! ≈ 4×10²⁶ possible alphabets — brute force is hopeless. But the scoring trick makes it tractable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Seed with frequency analysis.&lt;/strong&gt; Count letters in the ciphertext; map the most common cipher letter to E, the next to T, and so on. This is usually 30–60% correct — a decent starting point, not the answer. (You can do this step by hand with a &lt;a href="https://textmachine.org/en/text-tools/frequency-analysis" rel="noopener noreferrer"&gt;frequency analysis tool&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Improve by local search.&lt;/strong&gt; Swap two letters in the key, re-score, keep the swap if the score went up. Repeat. This is hill-climbing — and on its own it &lt;strong&gt;gets stuck in local optima&lt;/strong&gt;: a key that's better than all its neighbors but still wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape local optima with simulated annealing.&lt;/strong&gt; The fix is to &lt;em&gt;sometimes accept a worse swap&lt;/em&gt;, with a probability that starts high and "cools" toward zero. Early on the search roams freely and jumps out of bad valleys; late on it behaves like pure hill-climbing and locks onto the peak. Run a few random restarts and keep the best result. This reliably recovers normal English prose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's precisely what the &lt;a href="https://textmachine.org/en/text-tools/substitution-solver" rel="noopener noreferrer"&gt;substitution cipher solver&lt;/a&gt; does — frequency-seeded, then simulated annealing on trigram fitness — and it recovers both the message &lt;em&gt;and&lt;/em&gt; the full cipher alphabet with no key or crib. Paste a cryptogram and it solves in well under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cracking a Vigenère without the keyword
&lt;/h2&gt;

&lt;p&gt;Vigenère uses a repeating keyword, so it's polyalphabetic — letter frequencies are smeared flat and the substitution trick above doesn't directly apply. You break it in two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the key &lt;em&gt;length&lt;/em&gt;.&lt;/strong&gt; Two classic methods. &lt;a href="https://textmachine.org/en/text-tools/kasiski-examination" rel="noopener noreferrer"&gt;&lt;strong&gt;Kasiski examination&lt;/strong&gt;&lt;/a&gt; looks for repeated sequences in the ciphertext and measures the distances between them — those distances tend to be multiples of the key length. The &lt;a href="https://textmachine.org/en/text-tools/index-of-coincidence-calculator" rel="noopener noreferrer"&gt;&lt;strong&gt;Index of Coincidence&lt;/strong&gt;&lt;/a&gt; approach tries each candidate length and watches for the one where the slices look like natural (peaky) English. Run both, because they fail in different ways: Kasiski needs repeats, which short or repetitive text may not supply, while the IoC needs enough letters per column to be stable. When the two agree you almost certainly have the right length; when they disagree you do not have it yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solve each column independently.&lt;/strong&gt; Once you know the key length L, every L-th letter was enciphered with the &lt;em&gt;same&lt;/em&gt; shift — so the ciphertext splits into L columns, and &lt;strong&gt;each column is just a Caesar cipher.&lt;/strong&gt; Solve each one by frequency / chi-squared against English (only 26 shifts per column), and you've recovered the keyword letter by letter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The robust way to drive this — and what the &lt;a href="https://textmachine.org/en/text-tools/vigenere-solver" rel="noopener noreferrer"&gt;Vigenère solver&lt;/a&gt; does — is to &lt;strong&gt;solve at every plausible key length, then rank the resulting decryptions by English fitness&lt;/strong&gt; and present the best, rather than committing to a single length guessed from a threshold (which fails on repetitive plaintext). A monoalphabetic message naturally collapses to a one-letter key, so the same tool degrades gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  When automatic solving struggles
&lt;/h2&gt;

&lt;p&gt;Auto-solvers are statistical, so they need enough text to be confident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too short.&lt;/strong&gt; Under ~40–50 letters there often isn't enough signal; the trigram statistics are noisy. Get more ciphertext if you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not English.&lt;/strong&gt; The fitness table is language-specific. A French or German plaintext needs a French/German n-gram model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homophones, nulls, or padding.&lt;/strong&gt; Homophonic substitution (several cipher symbols per plaintext letter) and inserted null characters break the one-to-one assumption — identify and strip those first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not actually a simple substitution/Vigenère.&lt;/strong&gt; If the solver can't find anything English-like at any setting, re-check the cipher type — start again with the &lt;a href="https://textmachine.org/en/text-tools/cipher-identifier" rel="noopener noreferrer"&gt;cipher identifier&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two-minute version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify&lt;/strong&gt; the cipher (character set, IoC, structure) — or confirm it's a cryptogram / Vigenère.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paste it into the matching auto-solver&lt;/strong&gt; — &lt;a href="https://textmachine.org/en/text-tools/substitution-solver" rel="noopener noreferrer"&gt;substitution&lt;/a&gt; for cryptograms, &lt;a href="https://textmachine.org/en/text-tools/vigenere-solver" rel="noopener noreferrer"&gt;Vigenère&lt;/a&gt; for keyword ciphers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read off the plaintext and the recovered key.&lt;/strong&gt; If it stalls, check the message length and language, and re-confirm the cipher type.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No key, no problem — the statistics of English do the work for you. All of these run entirely in your browser; nothing you paste is uploaded.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>algorithms</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I shipped a Kasiski calculator with 21 passing tests. It got 3 of 5 key lengths wrong.</title>
      <dc:creator>이준김</dc:creator>
      <pubDate>Fri, 21 Aug 2026 21:33:46 +0000</pubDate>
      <link>https://dev.to/textmachine/i-shipped-a-kasiski-calculator-with-21-passing-tests-it-got-3-of-5-key-lengths-wrong-123l</link>
      <guid>https://dev.to/textmachine/i-shipped-a-kasiski-calculator-with-21-passing-tests-it-got-3-of-5-key-lengths-wrong-123l</guid>
      <description>&lt;p&gt;Kasiski examination is the oldest way to break a Vigenère cipher. Find sequences&lt;br&gt;
that repeat in the ciphertext, measure the gaps between them, and factor the&lt;br&gt;
gaps. Because the key repeats on a fixed cycle, a repeat that comes from the&lt;br&gt;
&lt;em&gt;same plaintext encrypted at the same key offset&lt;/em&gt; sits at a distance that is a&lt;br&gt;
multiple of the key length. Factor enough of those distances and the key length&lt;br&gt;
should fall out.&lt;/p&gt;

&lt;p&gt;The standard procedure, in every textbook I have read and every implementation I&lt;br&gt;
have written, ends like this: &lt;strong&gt;tally how many distances each candidate factor&lt;br&gt;
divides, and take the factor with the biggest tally.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That last step is wrong. Not subtly wrong on adversarial input — wrong on&lt;br&gt;
ordinary English prose, most of the time, in a way that a unit test suite will&lt;br&gt;
cheerfully certify as correct.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tests were green and the tool was broken
&lt;/h2&gt;

&lt;p&gt;My implementation had 21 unit tests. All passing. Then I ran it on five&lt;br&gt;
realistic ciphertexts, enciphered with five realistic keys, and read the number&lt;br&gt;
the page prints in the largest font:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;True key length&lt;/th&gt;
&lt;th&gt;What it answered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;7 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3&lt;/strong&gt; ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2&lt;/strong&gt; ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of five. And look at the failures: 3 divides 6, 3 divides 9, 2 divides&lt;br&gt;
13... no it does not. 2 divides &lt;em&gt;nothing&lt;/em&gt; about 13. It just wins anyway.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the fixtures lied
&lt;/h2&gt;

&lt;p&gt;Every fixture in those 21 tests was a hand-built string, and I built each one so&lt;br&gt;
that its repeat distances were clean multiples of the key length. That is what&lt;br&gt;
the method is &lt;em&gt;supposed&lt;/em&gt; to produce, so it felt like the honest thing to test.&lt;/p&gt;

&lt;p&gt;It is not. On real text, most repeated trigrams are coincidences. &lt;code&gt;THE&lt;/code&gt; appears&lt;br&gt;
everywhere; two occurrences of &lt;code&gt;THE&lt;/code&gt; at unrelated key offsets encipher&lt;br&gt;
differently, but plenty of other short sequences collide by accident and&lt;br&gt;
contribute a distance that carries no information about the key at all.&lt;/p&gt;

&lt;p&gt;A hand-built fixture has &lt;strong&gt;no coincidental repeats&lt;/strong&gt;. So it never exercises the&lt;br&gt;
one thing the ranking step exists to survive.&lt;/p&gt;
&lt;h2&gt;
  
  
  What each factor scores for nothing
&lt;/h2&gt;

&lt;p&gt;Here is the arithmetic the tally step skips.&lt;/p&gt;

&lt;p&gt;Take a set of distances with no signal in it whatsoever — pure noise. How many&lt;br&gt;
does the factor 2 divide? &lt;strong&gt;Half of them.&lt;/strong&gt; How many does 3 divide? &lt;strong&gt;A third.&lt;/strong&gt;&lt;br&gt;
How many does 13 divide? One in thirteen.&lt;/p&gt;

&lt;p&gt;So the raw tally is not a measurement of evidence. It is a measurement of&lt;br&gt;
evidence &lt;em&gt;plus&lt;/em&gt; a free head start that shrinks as the factor grows. Ranking&lt;br&gt;
small factors against large ones on that number is like ranking sprinters&lt;br&gt;
against marathoners by how long they were on the track.&lt;/p&gt;

&lt;p&gt;The fix is to divide the head start out. Call it &lt;strong&gt;lift&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;lift(f) = f × (distances divisible by f) / (total distances)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lift = 1.0&lt;/code&gt; means "exactly what chance would give you". &lt;code&gt;lift = 3.2&lt;/code&gt; means&lt;br&gt;
"3.2× more than chance". Now every candidate is on the same scale.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lift fixes half of it, and creates the other half
&lt;/h2&gt;

&lt;p&gt;Let &lt;code&gt;D&lt;/code&gt; be the total number of distances and &lt;code&gt;p&lt;/code&gt; the fraction of them that are&lt;br&gt;
genuine — actual multiples of the true key length &lt;code&gt;L&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a proper divisor &lt;code&gt;d&lt;/code&gt; of &lt;code&gt;L&lt;/code&gt;:&lt;/strong&gt; every genuine distance is divisible by &lt;code&gt;d&lt;/code&gt;&lt;br&gt;
(since &lt;code&gt;d | L | kL&lt;/code&gt;), and the noise contributes &lt;code&gt;1/d&lt;/code&gt; of the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hits/D  = p + (1−p)/d
lift(d) = d × (p + (1−p)/d) = p·d + (1−p)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since &lt;code&gt;d &amp;lt; L&lt;/code&gt;, that is strictly less than &lt;code&gt;lift(L) = p·L + (1−p)&lt;/code&gt;. Divisors&lt;br&gt;
solved. Lift demotes them automatically, with no special case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For a multiple &lt;code&gt;kL&lt;/code&gt;:&lt;/strong&gt; a genuine distance &lt;code&gt;mL&lt;/code&gt; is divisible by &lt;code&gt;kL&lt;/code&gt; only when&lt;br&gt;
&lt;code&gt;k | m&lt;/code&gt;, so roughly &lt;code&gt;p/k&lt;/code&gt; of them survive, and the noise contributes&lt;br&gt;
&lt;code&gt;(1−p)/(kL)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hits/D    = p/k + (1−p)/(kL)
lift(kL)  = kL × (p/k + (1−p)/(kL)) = p·L + (1−p)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;lift(L)&lt;/code&gt;. &lt;strong&gt;Exactly.&lt;/strong&gt; Not approximately, not usually — the multiples&lt;br&gt;
of the true key length tie with the true key length on lift, forever, at every&lt;br&gt;
sample size. Lift cannot break that tie, because there is nothing left in it to&lt;br&gt;
break the tie &lt;em&gt;with&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tie-breaker is the noise, not the signal
&lt;/h2&gt;

&lt;p&gt;The two candidates have the same expected lift. What differs is how far each is&lt;br&gt;
&lt;em&gt;entitled&lt;/em&gt; to wander on its own.&lt;/p&gt;

&lt;p&gt;A factor &lt;code&gt;f&lt;/code&gt; divides a random distance with probability &lt;code&gt;1/f&lt;/code&gt;. Over &lt;code&gt;D&lt;/code&gt;&lt;br&gt;
distances that is a binomial, and after scaling by &lt;code&gt;f&lt;/code&gt; the standard deviation of&lt;br&gt;
the lift works out to &lt;code&gt;sqrt((f−1)/D)&lt;/code&gt;. Larger factors are noisier — of course&lt;br&gt;
they are; they are estimating a rarer event from the same sample.&lt;/p&gt;

&lt;p&gt;So measure each candidate in units of its own noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z(f) = (lift(f) − 1) / sqrt((f − 1) / D)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Substitute the two cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z(L)  = p·(L−1) / sqrt((L−1)/D)  = p·sqrt((L−1)·D)
z(kL) = p·(L−1) / sqrt((kL−1)/D) &amp;lt; z(L)      because kL − 1 &amp;gt; L − 1
z(d)  = p·(d−1) / sqrt((d−1)/D)  = p·sqrt((d−1)·D) &amp;lt; z(L)   because d &amp;lt; L
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;z(L)&lt;/code&gt; is strictly the maximum.&lt;/strong&gt; Divisors lose because their lift is smaller;&lt;br&gt;
multiples lose because their noise is larger. One formula, both failure&lt;br&gt;
directions, and — the part I care about most — &lt;strong&gt;no tuned constants.&lt;/strong&gt; The&lt;br&gt;
previous version of this code had a hand-picked threshold and a "skip obvious&lt;br&gt;
artefacts" list. Both are now gone, because the arithmetic does their job.&lt;/p&gt;

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

&lt;p&gt;Same engine, same prose, sweeping every key length from 2 to 16:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Exact hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;330 letters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12 / 15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;698 letters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15 / 15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,396 letters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15 / 15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three misses at 330 letters are worth looking at, because of &lt;em&gt;how&lt;/em&gt; they are&lt;br&gt;
wrong. They are 5 answered as 15, 7 as 14, and 10 as 20 — every one a &lt;strong&gt;multiple&lt;/strong&gt;&lt;br&gt;
of the true length, never a divisor. That is the residue of the tie described&lt;br&gt;
above: multiples match the true length exactly on lift, and at 330 letters there&lt;br&gt;
is not enough data for the noise term to finish separating them. It is also the&lt;br&gt;
benign direction to fail in. A multiple is consistent with the same distances&lt;br&gt;
and splitting into 15 columns still recovers a 5-letter key, just with a third&lt;br&gt;
of the data per column. A divisor is flatly wrong, and it no longer happens at&lt;br&gt;
any sample size I have measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one bug I would have shipped anyway
&lt;/h2&gt;

&lt;p&gt;There was a second defect underneath, and it is worth naming because it is a&lt;br&gt;
&lt;em&gt;shape&lt;/em&gt;, not a fact about ciphers.&lt;/p&gt;

&lt;p&gt;The old code ranked first, then walked the ranked list skipping artefacts. That&lt;br&gt;
loop only ever demotes an artefact that appears &lt;strong&gt;after&lt;/strong&gt; the real answer. So&lt;br&gt;
whichever artefact happened to outrank the real answer got taken first, and the&lt;br&gt;
rule that existed to catch exactly that never ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reduce the field before you sort it, never during.&lt;/strong&gt; I have now found this same&lt;br&gt;
inverted-order bug in two separate tools in the same codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I would tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before ranking by a count, ask what each candidate scores for nothing, and
divide it out.&lt;/strong&gt; A leaderboard over quantities with different baselines is
not a leaderboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then ask how far that corrected figure wanders on its own, and divide that
out too.&lt;/strong&gt; Half the tie-breaks live in the variance, not the mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hand-built fixture cannot test a statistic.&lt;/strong&gt; Sweep the whole parameter
range on real input. The arithmetic still deserves its unit test — just do
not mistake that for a test of the statistic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Both tools run entirely in the browser, no upload, no account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://textmachine.org/en/text-tools/kasiski-examination" rel="noopener noreferrer"&gt;Kasiski examination&lt;/a&gt;
— shows the lift and the z-score for every factor, so you can see the ranking
rather than trust it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://textmachine.org/en/text-tools/index-of-coincidence-calculator" rel="noopener noreferrer"&gt;Index of coincidence calculator&lt;/a&gt;
— the other route to key length, and a good cross-check. If Kasiski and the IC
disagree, you have not found the key length yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paste a Vigenère ciphertext into both. If they agree, split the text into that&lt;br&gt;
many columns and each column is a plain Caesar shift, which&lt;br&gt;
&lt;a href="https://textmachine.org/en/text-tools/frequency-analysis" rel="noopener noreferrer"&gt;frequency analysis&lt;/a&gt;&lt;br&gt;
finishes in a few seconds.&lt;/p&gt;

</description>
      <category>cryptography</category>
      <category>testing</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
