<?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: Nobody</title>
    <description>The latest articles on DEV Community by Nobody (@nobody_4fb7fd10637d3b88a3).</description>
    <link>https://dev.to/nobody_4fb7fd10637d3b88a3</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%2F4138425%2F86a431f2-601e-43f3-b07b-2d71ced470e3.png</url>
      <title>DEV Community: Nobody</title>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nobody_4fb7fd10637d3b88a3"/>
    <language>en</language>
    <item>
      <title>Why a Google Photos Takeout scrambles your timeline, and how the dates get back in</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:56:22 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/why-a-google-photos-takeout-scrambles-your-timeline-and-how-the-dates-get-back-in-10j9</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/why-a-google-photos-takeout-scrambles-your-timeline-and-how-the-dates-get-back-in-10j9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/why-google-takeout-scrambles-your-timeline/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have ever moved out of Google Photos with Google Takeout, you probably know the moment. You import the export into Apple Photos, Lightroom or a NAS, and a big part of your library lands on one day: the day you made the export. Screenshots from 2018, a WhatsApp picture from 2019, the edits you made in Google Photos, a lot of videos. All of it filed under last Tuesday.&lt;/p&gt;

&lt;p&gt;Nothing is lost. Takeout does give you the dates. It just puts them somewhere your photo app never looks. I built &lt;a href="https://takeoutfix.getnobody.org" rel="noopener noreferrer"&gt;Takeoutfix&lt;/a&gt; to put them back, and this post is what I learned about how Takeout stores metadata and why the timeline breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Takeout actually gives you
&lt;/h2&gt;

&lt;p&gt;A Takeout of Google Photos is a set of zip files (often several, each up to 50 GB). Inside them is a folder per year, &lt;code&gt;Photos from 2019&lt;/code&gt; and so on, plus one folder per album. Next to almost every photo and video sits a JSON file. Here is one from the sample library on the site (a synthetic library I generated; no real photos):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IMG_4410.jpg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Torre de Belém at closing time"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imageViews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creationTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1555187469"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"formatted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apr 13, 2019, 8:31:09 PM UTC"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"photoTakenTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1555171512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"formatted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apr 13, 2019, 4:05:12 PM UTC"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"geoData"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;38.6916&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-9.216&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"altitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitudeSpan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitudeSpan"&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"geoDataExif"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;38.6916&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-9.216&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"altitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitudeSpan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitudeSpan"&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"photoLastModifiedTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1555187469"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"formatted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apr 13, 2019, 8:31:09 PM UTC"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in there matter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;photoTakenTime&lt;/code&gt; is when the photo was taken, as Unix seconds in a string. This is the date you want.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;creationTime&lt;/code&gt; is when the file was uploaded to Google Photos. It looks like a date you could use, and it's often years off for anything you backed up late. I only fall back to it when there is no &lt;code&gt;photoTakenTime&lt;/code&gt;, and the report says so for every file where that happened.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;geoData&lt;/code&gt; holds the place, and &lt;code&gt;0, 0&lt;/code&gt; means "no place", not a spot in the Gulf of Guinea. A caption you typed in Google Photos lives in &lt;code&gt;description&lt;/code&gt; and nowhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the part that causes the trouble: every file in the zip is stamped with the time the export was made. In the sample, that's 20 September 2026 for all 20 files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the photo app picks export day
&lt;/h2&gt;

&lt;p&gt;A photo app doesn't know Takeout exists. When it imports a file, it looks for a date inside the file (EXIF &lt;code&gt;DateTimeOriginal&lt;/code&gt; in a JPEG or HEIC, the creation time in a video's header). If it finds one, the photo goes to the right day. If it doesn't, it falls back to the file's own modified date, which Takeout set to export day. The JSON next to the file is never opened.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/why-google-takeout-scrambles-your-timeline/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Places and captions have the same problem. A place you added in Google Photos, or a caption you typed, exists only in the JSON. So even the photos that land on the right day can arrive without their place.&lt;/p&gt;

&lt;p&gt;Here is the sample library on the site, before and after. Before, by the only date an importer can find for most of these files; after, by the capture date that Takeoutfix wrote back in:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/why-google-takeout-scrambles-your-timeline/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The names keep changing
&lt;/h2&gt;

&lt;p&gt;The obvious fix is "for every photo, open the JSON with the same name plus .json and copy the date in". That used to be close to true. It isn't any more, and this is where tools written before 2024 fall over.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classic:&lt;/strong&gt; &lt;code&gt;IMG_1234.jpg&lt;/code&gt; and &lt;code&gt;IMG_1234.jpg.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Since 2024:&lt;/strong&gt; &lt;code&gt;IMG_1234.jpg.supplemental-metadata.json&lt;/code&gt;. A tool that looks for &lt;code&gt;IMG_1234.jpg.json&lt;/code&gt; finds nothing and leaves the photo undated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cut short:&lt;/strong&gt; JSON names are kept to 51 characters, &lt;code&gt;.json&lt;/code&gt; included (this is the limit people report in the issue threads of Takeout tools). With the longer suffix, far more names hit it, and the cut lands anywhere: &lt;code&gt;.supplemental-metada.json&lt;/code&gt;, &lt;code&gt;.suppl.json&lt;/code&gt;, &lt;code&gt;.s.json&lt;/code&gt;, or just &lt;code&gt;..json&lt;/code&gt;. For a long file name the cut lands inside the name itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates:&lt;/strong&gt; when two photos share a name, the second is &lt;code&gt;IMG_1234(1).jpg&lt;/code&gt;, and its JSON is &lt;code&gt;IMG_1234.jpg(1).json&lt;/code&gt;, with the counter moved after the extension.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edits:&lt;/strong&gt; &lt;code&gt;IMG_1234-edited.jpg&lt;/code&gt; has no JSON of its own (in a German export it's &lt;code&gt;-bearbeitet&lt;/code&gt;, in a French one &lt;code&gt;-modifié&lt;/code&gt;). It has to borrow the original's.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/why-google-takeout-scrambles-your-timeline/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the matcher is a series of passes over each folder, from strict to loose: the name itself (with every &lt;code&gt;supplemental-metadata&lt;/code&gt; variant and the &lt;code&gt;(1)&lt;/code&gt; rule), then the &lt;code&gt;title&lt;/code&gt; inside the JSON (which keeps the full, uncut name), then cut names by prefix, then edited copies, then Live Photo videos (&lt;code&gt;IMG_3001.MP4&lt;/code&gt; takes the date of &lt;code&gt;IMG_3001.JPG&lt;/code&gt;), and last, album copies that borrow the JSON of the same photo in its year folder. Each pass uses maps keyed by name, so a folder of thousands of files is matched in one go rather than by comparing every file with every JSON. It never pairs a file with a JSON from another folder, except for album copies. When nothing matches, the scan says so and shows the names.&lt;/p&gt;

&lt;p&gt;Takeout also splits big libraries over several zips, and a photo and its JSON can land in different parts. So every part is indexed before anything is matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the dates back in
&lt;/h2&gt;

&lt;p&gt;Once a file has its JSON, the date has to go inside it, in the place each format keeps it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JPEG:&lt;/strong&gt; EXIF &lt;code&gt;DateTimeOriginal&lt;/code&gt;, plus &lt;code&gt;OffsetTimeOriginal&lt;/code&gt;, GPS and the caption. I don't re-encode the picture. The EXIF block is replaced and every other byte of the file is copied as it was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC, PNG, WebP and a few others:&lt;/strong&gt; these are written by ExifTool, Phil Harvey's Perl program, compiled to WebAssembly so it runs in the browser tab. On a 4.4 MB synthetic HEIC it took about 0.4 seconds per file in Chrome on a Mac, much of it ExifTool starting up again for each file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MP4 and MOV:&lt;/strong&gt; a video stores its creation time in three header boxes (&lt;code&gt;mvhd&lt;/code&gt;, &lt;code&gt;tkhd&lt;/code&gt;, &lt;code&gt;mdhd&lt;/code&gt;) as seconds since 1 January 1904, in UTC. Those are fixed-size fields, so the date can be patched while the video streams through, without ever holding a large video in memory and without changing any box size (which would break the offsets that point into the video data). Place and caption go in through ExifTool for videos up to 64 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Camera RAW:&lt;/strong&gt; never rewritten. It gets an &lt;code&gt;.xmp&lt;/code&gt; file next to it, which Lightroom and darktable read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail I had to decide: EXIF's &lt;code&gt;DateTimeOriginal&lt;/code&gt; has no time zone. It's a local time, "2019:04:13 17:05:12", and the JSON gives an instant in UTC. So I write the local time in a time zone you pick (your computer's by default) and add &lt;code&gt;OffsetTimeOriginal&lt;/code&gt;, so apps that read the offset place the photo exactly. For photos taken while travelling, the local time will be in your home zone. The report says that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a browser can't do
&lt;/h2&gt;

&lt;p&gt;A web page can write files into a folder you choose (in Chrome and Edge on a computer), but it can't set their modified dates. Every fixed file gets today's date as its file date. That's why everything above is about dates inside the files: that is what Apple Photos, Lightroom and the NAS photo apps sort by. And a Live Photo comes out as a dated photo and a dated short video, not one merged Live Photo.&lt;/p&gt;

&lt;p&gt;Nothing is uploaded at any point. The page reads the zips where they are, writes the fixed copy to your disk, and the server only ever sees a hash of the file list and two counts, to know whether you paid.&lt;/p&gt;

&lt;p&gt;If you have a Takeout sitting in your Downloads folder, &lt;a href="https://takeoutfix.getnobody.org" rel="noopener noreferrer"&gt;try Takeoutfix&lt;/a&gt;. The scan, the match report and 20 fixed files are free, so you can import those and check before paying. The whole library is $9, once, and re-running the same library with other options is free for 7 days.&lt;/p&gt;

</description>
      <category>photos</category>
      <category>metadata</category>
      <category>exif</category>
      <category>browsers</category>
    </item>
    <item>
      <title>How a PNG becomes a cut file: image tracing, step by step</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:56:09 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/how-a-png-becomes-a-cut-file-image-tracing-step-by-step-47b1</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/how-a-png-becomes-a-cut-file-image-tracing-step-by-step-47b1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/how-image-tracing-works/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Cricut or a laser doesn't know what a pixel is. It follows paths: closed outlines, each one a loop the blade or beam can travel from start back to start. So the job of a tracer is to look at a grid of colored squares and write down where the edges are, in as few curves as it can without drifting from the picture.&lt;/p&gt;

&lt;p&gt;I built one for &lt;a href="https://cutline.getnobody.org" rel="noopener noreferrer"&gt;Cutline&lt;/a&gt;, which turns a PNG or JPG into files for Cricut, Silhouette, Glowforge, LightBurn and vinyl plotters. It runs in the browser tab. Here is every step it takes, with real numbers from the sample badge on Cutline's home page: a 900 x 900 px drawing with paper grain, 40 dust specks and 10 pinholes, which is what a phone photo of a print looks like.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-image-tracing-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Quantize: 220 colors down to 5
&lt;/h2&gt;

&lt;p&gt;The badge is drawn with five paints: paper, cream, ink, pine green and a mustard sun. But anti-aliased edges and paper grain mean the file actually holds &lt;strong&gt;220 distinct RGB values&lt;/strong&gt;. Vinyl comes in sheets of one color, so the first job is to decide which five colors the image "really" has, and which one each pixel belongs to.&lt;/p&gt;

&lt;p&gt;I use k-means, with two changes that matter in a browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cluster a histogram, not pixels.&lt;/strong&gt; Every pixel drops into one of 32,768 bins (5 bits per channel), and k-means runs over the bins weighted by their counts. A 4096 px image has 16 million pixels but never more than 32,768 bins, so the clustering costs the same at any size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed it the same way every time.&lt;/strong&gt; The first center is the fullest bin, and each next one is the bin farthest from the centers picked so far, weighted by count. Random seeding would give a slightly different palette on every slider move, and the layers would flicker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A light blur runs first so single grainy pixels don't vote for a color of their own. But the final layer color is the average of each cluster's &lt;em&gt;unblurred&lt;/em&gt; pixels, because the blur shouldn't tint the vinyl.&lt;/p&gt;

&lt;p&gt;On the badge this takes about 55 ms and finds paper, cream, ink, green and sun. Paper covers most of the image's border, so it becomes the background and gets dropped: nobody cuts the paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Masks and cleanup
&lt;/h2&gt;

&lt;p&gt;Each remaining color becomes a mask: a grid of yes or no, one bit per pixel. Straight out of quantization, those masks are a mess:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-image-tracing-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A thousand pieces of sun would mean a thousand tiny cuts to weed by hand. So the cleanup finds connected regions (4-connected, so pixels touching only at a corner don't count as joined) and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;removes specks&lt;/strong&gt;: any region smaller than the speck size slider. On the sample that is anything under 146 pixels at preview size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fills pinholes&lt;/strong&gt;: any hole that small, as long as it doesn't touch the image border.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stacks the layers&lt;/strong&gt; for layered vinyl: each layer also covers every layer above it, so the colors overlap a little instead of meeting edge to edge. Vinyl never lands exactly where you put it, and an exact butt joint shows the shirt through a hairline gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last promise is where I found a bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hairline gap
&lt;/h3&gt;

&lt;p&gt;My first cut looked right in the color view. In the cut view, the bottom layer of the badge had two thin crescents running along the inside of the black ring: holes in a layer that should have been one solid disc.&lt;/p&gt;

&lt;p&gt;The cause was the anti-aliasing. Where black meets cream, the edge pixels are a blend, and k-means doesn't always give a blend to either neighbor: some of them land closer to a &lt;em&gt;third&lt;/em&gt; color. They formed thin, disconnected slivers of that color along the ring, each smaller than the speck size, so cleanup deleted them. Deleted pixels belong to no layer. Stacking can only OR together what exists, so the gap survived into the file.&lt;/p&gt;

&lt;p&gt;My first fix was to merge every small region into whichever color surrounds most of it, instead of deleting it. That closed the gaps, but it wrecked the outer edge of the badge, where the same kind of slivers sit between the ink and the paper. Some merged outward and some merged inward, and the smooth circle came out bumpy:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-image-tracing-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fix that stuck is narrower. After specks are removed, take the union of every kept color and look at its holes. A real hole, like the inside of a letter O, is background, so it holds background pixels. A gap left by deleted slivers holds &lt;em&gt;none&lt;/em&gt;. So any enclosed hole with no background pixel in it is a gap, and it goes to the bottom layer. The badge's bottom layer is now one disc with no holes, and its outline is back to 20 segments.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Outline: marching squares
&lt;/h2&gt;

&lt;p&gt;Now each mask is clean, and the tracer needs its edges. The tracing library I use, &lt;a href="https://github.com/jankovicsandras/imagetracerjs" rel="noopener noreferrer"&gt;imagetracerjs&lt;/a&gt; (public domain, about 40,000 downloads a week), does this with a version of marching squares.&lt;/p&gt;

&lt;p&gt;It slides a 2 x 2 window over the mask. The four pixels under the window are each in or out, so every position is one of 16 cases, stored as a 4-bit number. Case 0 (all out) and case 15 (all in) mean no edge passes here. Every other case says exactly which way the edge runs through that corner. Starting from any edge corner and following the cases, you walk around the shape and come back to where you started, which gives a closed loop for free.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-image-tracing-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On the badge's outer circle that loop has &lt;strong&gt;3,192 points&lt;/strong&gt;, one per pixel corner along the edge. A blade following it would stutter through three thousand tiny right angles.&lt;/p&gt;

&lt;p&gt;Before fitting, the library moves each point to the midpoint of its edge step. That turns the staircase into an 8-direction path: diagonals where the pixels step evenly, straight runs where they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Fit: lines first, then curves
&lt;/h2&gt;

&lt;p&gt;The last step turns thousands of points into a few segments, with a greedy recursive fit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cut the path into runs that only use two of the 8 directions.&lt;/li&gt;
&lt;li&gt;Try a straight line from the start of the run to its end. If every point is within the line tolerance, keep it.&lt;/li&gt;
&lt;li&gt;If not, find the point farthest from that line and fit a quadratic curve through the start, that point and the end. If every point is within the curve tolerance, keep the curve.&lt;/li&gt;
&lt;li&gt;If not, split the run at the worst point and do both halves again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The two tolerances are the whole trade-off between faithful and smooth, and in Cutline they are the Detail and Smoothing sliders. Here is what Smoothing does to the badge's outer circle:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-image-tracing-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Past a point, more smoothing starts to round off corners you wanted sharp, like the peaks of the mountains. That's why it's a slider and not a constant.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://potrace.sourceforge.net/" rel="noopener noreferrer"&gt;Potrace&lt;/a&gt; is the classic alternative. It finds the polygon with the fewest sides that stays within a pixel of the edge, then decides corner by corner whether to round it with a Bezier curve. It often makes nicer curves, but it is GPL, and I wanted a permissively licensed library I could ship in a paid product without thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make it a file a machine accepts
&lt;/h2&gt;

&lt;p&gt;The fitted segments go through a few more rules before anything is written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every path is closed&lt;/strong&gt; and ends exactly where it started, so the cutting app treats it as a shape and not a stray line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordinates go into a fixed 1000-unit box&lt;/strong&gt;, rounded to 2 decimals, so the same design gives the same file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Holes stay with their shape&lt;/strong&gt;: the inside of the ring is part of the ring's path, filled with the even-odd rule, so it cuts as a hole instead of a second piece.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result for the badge: &lt;strong&gt;4 layers, 6 shapes, 7 closed paths and 203 nodes&lt;/strong&gt;, from 810,000 pixels. The whole preview trace takes about 170 ms on my laptop, which is fast enough to re-run on every slider move.&lt;/p&gt;

&lt;p&gt;From there the output is just formatting. Cricut Design Space wants an SVG sized in inches with one group per color. Silhouette Studio's Basic Edition only reads DXF, which has no fills, so every outline becomes a polyline. A laser wants red hairline strokes for cutting and black fills for engraving. The tracing is the hard part; the file formats are bookkeeping.&lt;/p&gt;

&lt;p&gt;If you have a logo or a drawing you'd like on vinyl, &lt;a href="https://cutline.getnobody.org" rel="noopener noreferrer"&gt;try it on Cutline&lt;/a&gt;. The preview and all the sliders are free, and the files are $2 per image, paid once.&lt;/p&gt;

</description>
      <category>graphics</category>
      <category>algorithms</category>
      <category>vectors</category>
      <category>cricut</category>
    </item>
    <item>
      <title>How Fillable finds the blanks in a flat PDF form</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:50:27 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/how-fillable-finds-the-blanks-in-a-flat-pdf-form-68g</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/how-fillable-finds-the-blanks-in-a-flat-pdf-form-68g</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/how-fillable-finds-form-fields/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A lot of forms still arrive as flat PDFs: an intake form, a volunteer application, an order sheet. Nothing on them can be typed into, so people print them, fill them in by pen and scan them back. The tools that add form fields mostly want a monthly plan for something you need once. &lt;a href="https://fillable.getnobody.org" rel="noopener noreferrer"&gt;Fillable&lt;/a&gt; is the one-off version: you drop in the PDF, it finds the fields, you fix what it got wrong and pay $4 once for that document.&lt;/p&gt;

&lt;p&gt;Finding the fields is the interesting part, and it all runs in the browser. This post covers how it works.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-fillable-finds-form-fields/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PDF page actually contains
&lt;/h2&gt;

&lt;p&gt;A PDF page doesn't hold "a text field here" or even "a line here". It holds a content stream: a list of drawing operators like "move to", "line to", "rectangle", "stroke", "fill" and "show this text". pdf.js exposes that list through &lt;code&gt;page.getOperatorList()&lt;/code&gt;, so the first job is to replay it and write down every straight line and rectangle it draws.&lt;/p&gt;

&lt;p&gt;Replaying it means keeping the graphics state the way a renderer does. Every &lt;code&gt;cm&lt;/code&gt; operator changes the current transform matrix, &lt;code&gt;q&lt;/code&gt; and &lt;code&gt;Q&lt;/code&gt; save and restore it, and a form XObject carries its own matrix on top. A line drawn at (0, 0) inside three nested transforms could be anywhere on the page, so every point goes through the full matrix before I keep it. The line width matters too, and it can come from a graphics state dictionary rather than the &lt;code&gt;w&lt;/code&gt; operator.&lt;/p&gt;

&lt;p&gt;Form generators disagree about how to draw a line. Some stroke a path. Some fill a rectangle 0.5 points tall. Some draw a box as four separate strokes. So a thin filled rectangle counts as a line, and rectangles are also rebuilt from separate lines that meet at their corners. That second step is also what finds every cell of a ruled table.&lt;/p&gt;

&lt;p&gt;Text comes from &lt;code&gt;page.getTextContent()&lt;/code&gt;, one run per item with its position measured from the baseline. Two extra rules come from real forms: a run of three or more underscores is a writing line typed as text, and characters like ☐ are checkboxes typed as text.&lt;/p&gt;

&lt;h2&gt;
  
  
  From lines and boxes to fields
&lt;/h2&gt;

&lt;p&gt;With geometry and text in PDF points, classification is a set of plain rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Checkboxes&lt;/strong&gt; are near-squares between 6 and 20 points. Each one takes the text to its right as its name. Short options also take the question on their row, so "Smoker? Yes / No" becomes &lt;code&gt;smoker_yes&lt;/code&gt; and &lt;code&gt;smoker_no&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boxes&lt;/strong&gt; that are empty become text fields, or multiline fields when they are taller than 36 points. If a label is printed inside the box, the field goes in the space under it or beside it. A box that contains other boxes is a panel, not a field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table cells&lt;/strong&gt; that are empty are named from their column header and row label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing lines&lt;/strong&gt; are split wherever text sits on them, so "Signature ____ Date ____" drawn as one long rule gives two fields. The label is the nearest text to the left, then below, then above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The kind comes from the label. "Date of birth" gets a date field and "Signature" a digital signature field (signing one needs a reader that supports digital signatures, such as Acrobat Reader; switch it to text for a typed name). Number fields are only for amounts and quantities. My first version also made phone and zip fields numbers, until I remembered what a number field does to a zip code: "02134" is shown as "2,134.00".&lt;/p&gt;

&lt;p&gt;Last, overlapping candidates are merged, everything is clamped to the page box, and names are made unique. The server clamps again when it writes the PDF, because it never trusts coordinates from the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scanned pages have no operators
&lt;/h2&gt;

&lt;p&gt;A scan is one big image and no text. When a page is mostly image and has almost no text runs, Fillable switches paths: it renders the page at 2 pixels per point, converts it to grayscale and picks a black-and-white threshold with Otsu's method (the gray level that best separates two groups of pixels, ink and paper).&lt;/p&gt;

&lt;p&gt;Then it looks for long dark runs along each row and each column, and stacks runs on neighbouring rows into lines with a thickness. Anything thicker than 4 points is a filled bar or a photo, not a rule. Rectangles come from the lines the same way as before, and a rectangle that is mostly ink inside is thrown away. The rest goes through the same classifier as a drawn form.&lt;/p&gt;

&lt;p&gt;There's no text recognition, so fields on a scan start unnamed. In the editor you select one, type a name and press Enter to jump to the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quarter of a degree
&lt;/h2&gt;

&lt;p&gt;My first version worked on a perfectly straight test scan and fell apart on the next one, which was tilted by 0.25 degrees. That tilt is invisible to the eye. But at 2 pixels per point, a line that rises by tan(0.25°) climbs one pixel every 229 pixels. A 468 point rule is 936 pixels wide, so it steps up four times, and each step splits it into a separate run. The big boxes lost their edges and the table lost a column.&lt;/p&gt;

&lt;p&gt;The fix is to straighten the page before looking for lines. To measure the tilt, I take the dark pixels and, for each candidate angle, count how many land on each row after rotating by that angle. At the right angle, all the pixels of each rule land on the same row, so the counts are very uneven. Summing the squared counts turns that into a score. Fillable tries every 0.05 degrees between -3 and 3, then every 0.01 degrees around the best angle. It rotates the bitmap, finds the lines, and maps each field's centre back to where it sits on the original page.&lt;/p&gt;

&lt;p&gt;On a letter page, the whole line and box search, straightening included, takes about 45 ms in my tests. The chart shows the difference on my sample intake form, scanned at different tilts:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-fillable-finds-form-fields/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How well it does
&lt;/h2&gt;

&lt;p&gt;These numbers are from one intake form I made for testing. It has 25 fields: writing lines, two boxes, five checkboxes, a comments box, a ruled table and a signature line. Real forms are messier, which is why every field can be checked and fixed before you pay.&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;Fields found&lt;/th&gt;
&lt;th&gt;Extra fields&lt;/th&gt;
&lt;th&gt;Right kind&lt;/th&gt;
&lt;th&gt;Time in Chrome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Drawn PDF&lt;/td&gt;
&lt;td&gt;25 of 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;11 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan, tilted 0.25°&lt;/td&gt;
&lt;td&gt;25 of 25&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;101 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the scan, the three extra fields are the table's header cells. Without text recognition they look like empty boxes. The three wrong kinds are the date and signature fields, which come out as plain text because there is no label to read.&lt;/p&gt;

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

&lt;p&gt;When you download, the PDF goes to the server once, together with the field list. The server writes real AcroForm fields with pdf-lib and sends the file straight back. The PDF is never stored: the server keeps its SHA-256 hash, page count and field count, so a payment unlocks exactly that document. Date fields carry their format, checkboxes are real checkboxes, and every field keeps its name. Text fields and checkboxes work in Acrobat Reader, Preview and browsers; date formatting needs a reader that runs form scripts, and signature fields a reader that supports digital signatures.&lt;/p&gt;

&lt;p&gt;Finding fields, editing and previewing are free. The fillable PDF is $4, once, for that document, with free re-downloads for 24 hours if you spot something to fix. There's no account and no monthly plan.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fillable.getnobody.org" rel="noopener noreferrer"&gt;Try Fillable&lt;/a&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>javascript</category>
      <category>imageprocessing</category>
      <category>fillable</category>
    </item>
    <item>
      <title>How a Word file becomes a printed book: line breaks, widows and the KDP gutter</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Fri, 25 Sep 2026 21:49:59 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/how-a-word-file-becomes-a-printed-book-line-breaks-widows-and-the-kdp-gutter-e0f</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/how-a-word-file-becomes-a-printed-book-line-breaks-widows-and-the-kdp-gutter-e0f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/how-book-typesetting-works/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Word document has no pages, not really. It has paragraphs, and Word decides where the pages fall while you type. A printed book is the opposite: every page is fixed, the left and right pages mirror each other, and the printer wants a PDF where nothing moves. Somebody has to decide where every line and every page ends.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://bookcut.getnobody.org" rel="noopener noreferrer"&gt;Bookcut&lt;/a&gt; to make that decision for self-published authors. You drop in the .docx, it finds the chapters, and it sets the book for an Amazon KDP paperback and an EPUB. All the typesetting runs in your browser tab. This post walks through the three problems that took the most thought, with numbers from the first three chapters of Pride and Prejudice, which is the sample text on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Breaking lines
&lt;/h2&gt;

&lt;p&gt;Book text is justified: every full line ends exactly at the right edge, so the spaces between words have to stretch or shrink. My engine gives a word space a natural width of 0.25 em, lets it shrink to 0.2 em and stretch to 0.45 em. Past 0.45 em a line looks loose, with visible holes between words, and the engine reports it.&lt;/p&gt;

&lt;p&gt;The obvious way to break lines is greedy: put as many words on the line as fit, then start the next one. It's fast and it never looks back, which is its problem. A good line early in the paragraph can force a terrible line three lines later.&lt;/p&gt;

&lt;p&gt;The better way comes from Donald Knuth and Michael Plass, who worked it out for TeX. Look at every possible way to break the whole paragraph, give each line a cost that grows sharply with how far its spaces stretch or shrink, and pick the set of breaks with the lowest total. It sounds expensive, but the best way to end a line after a given word doesn't depend on what comes later, so it's a shortest-path search over break points. My version is a simplified one: it adds a cost for each hyphenated line, and a very large one for two hyphenated lines in a row.&lt;/p&gt;

&lt;p&gt;I expected this to fix everything. So I measured it. I set every paragraph of the three chapters in the Classic design (EB Garamond at 11.5 pt), once with a greedy breaker and once with the total-fit breaker, and counted the lines whose spaces had to stretch past 0.45 em.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-book-typesetting-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things surprised me.&lt;/p&gt;

&lt;p&gt;On a narrow 5 x 8 page without hyphenation, the algorithm made no difference at all: 47 loose lines against 48. A line of that width holds about eight or nine words of Austen, and when the next word is "neighbourhood" there is simply no good place to break. Total fit can spread the pain around the paragraph, but it can't make a long word shorter.&lt;/p&gt;

&lt;p&gt;Hyphenation did most of the work. Turning it on at 5 x 8 cut the loose lines from 48 to 11, and on top of that total fit beat greedy by 18 to 11. At 6 x 9 the wider line gives both methods more room, and the gap is small once hyphenation is on. So Bookcut hyphenates by default, with rules taken from how book typesetters usually do it: only words of 6 letters or more, at least 3 letters before the hyphen and 2 after, and never on the last word of a paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Widows and orphans
&lt;/h2&gt;

&lt;p&gt;Once the lines exist, they have to be poured into pages. The rule every book designer knows: a paragraph should never leave its first line alone at the bottom of a page (an orphan), or its last line alone at the top of the next one (a widow). A heading also shouldn't sit at the bottom of a page with its text on the next.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-book-typesetting-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My page breaker handles this with a flag on every line: "ending the page after me would strand a line". It fills a page as far as it can, and if the last line it placed carries that flag, it walks back until it finds a line where a break is safe. The walk back is limited to 6 lines. Past that the page would look visibly short, so the engine fills the page anyway and lists the widow in the preview, where the author can decide. Headings are flagged the same way and stay with at least two lines of their text. A scene break never ends a page either, because at the foot of a page a reader can't tell it's there.&lt;/p&gt;

&lt;p&gt;Pages then get their furniture: running heads (the author on left pages, the title on right pages), page numbers, and none of either on chapter openers or blank pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The gutter depends on the page count
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me most. A thick paperback curves into its spine, so the inside margin (the gutter) has to be wider the more pages the book has, or the text disappears into the fold. KDP publishes the minimum on its help page for trim size, bleed and margins:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/how-book-typesetting-works/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now the loop: the margins decide how wide the lines are, the line width decides how many lines there are, and the number of lines decides the page count, which decides the margins. A book that sets at 300 pages with a 0.5 in gutter might need 0.625 in, and with the narrower lines it now sets at 304 pages. That's fine, 304 is still in the same band. But it has to be checked.&lt;/p&gt;

&lt;p&gt;Bookcut estimates the page count from the word count first, lays the book out with the margins for that estimate, and then looks at the real page count. If it landed in a different band, it lays the whole book out again with the new margins. Margins only ever get wider after the first correction, so the passes can't bounce between two bands forever, and there is a hard limit of 6 passes. Each design also has its own preferred margins, often wider than KDP's minimum for a short book, and the band only takes over once it asks for more than the design does. One of the tests sets a generated 99,000 word novel at 5 x 8 in, which comes out over 700 pages and ends with the 0.875 in gutter KDP asks for.&lt;/p&gt;

&lt;p&gt;A few more rules come after that. The page count must be even and at least 24, so the engine adds blank pages at the end. Recto (right-hand) pages have the gutter on the left and verso pages on the right. Every page is exactly the trim size with no bleed and no crop marks. The fonts (four open-licensed book faces) are embedded in the PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the author sees
&lt;/h2&gt;

&lt;p&gt;The preview in the browser draws each page as SVG from the same layout the PDF is written from, word by word at the same positions, so the pages you read are the pages KDP prints. Next to it is a check panel with the final page count, the gutter KDP asks for at that length and the one the book actually has, the smallest text size and the blank pages.&lt;/p&gt;

&lt;p&gt;If you have a manuscript waiting, &lt;a href="https://bookcut.getnobody.org" rel="noopener noreferrer"&gt;try Bookcut&lt;/a&gt;. Importing, fixing the outline and previewing every page are free. The print PDF and the EPUB are $12 for the book, once, and you can export again free for 30 days when you find a typo.&lt;/p&gt;

</description>
      <category>typography</category>
      <category>algorithms</category>
      <category>publishing</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Why your SQLite WAL file never shrinks</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Fri, 25 Sep 2026 15:08:40 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/why-your-sqlite-wal-file-never-shrinks-304f</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/why-your-sqlite-wal-file-never-shrinks-304f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/sqlite-wal-never-shrinks/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you run SQLite in WAL mode with a busy writer and a few readers, you may one day find a &lt;code&gt;-wal&lt;/code&gt; file of several gigabytes next to a database that is much smaller. Autocheckpoint is on, nothing reports an error, and &lt;code&gt;PRAGMA wal_checkpoint&lt;/code&gt; says it worked. I read the checkpoint code in SQLite 3.53.2, then built a small lab to watch it happen.&lt;/p&gt;

&lt;p&gt;The short version: when read transactions overlap so that at least one is always open, the checkpoint can never finish and the writer can never go back to frame 1. In my runs the WAL grew by &lt;strong&gt;11.9 MiB per second&lt;/strong&gt; for as long as the overlap lasted, and when the readers went away it &lt;strong&gt;stayed at 357 MiB&lt;/strong&gt;. A &lt;code&gt;wal_checkpoint(TRUNCATE)&lt;/code&gt; every 5 seconds kept it under 60 MiB in 2 of 3 runs (119 MiB in the run where one call came back busy), at the price of stalling the writer for up to 1.8 s each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How WAL mode commits and checkpoints
&lt;/h2&gt;

&lt;p&gt;In WAL mode a commit doesn't touch the database file. The writer appends the changed pages to the &lt;code&gt;-wal&lt;/code&gt; file as frames (a 24-byte header plus one page), and a shared-memory index in the &lt;code&gt;-shm&lt;/code&gt; file maps page numbers to the newest frame that holds them. Readers look there first and fall back to the database file.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;checkpoint&lt;/strong&gt; copies frames back into the database file. Two counters in the shared index drive everything (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L321-L401" rel="noopener noreferrer"&gt;&lt;code&gt;wal.c&lt;/code&gt; L321-L401&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mxFrame&lt;/code&gt; is the last committed frame.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nBackfill&lt;/code&gt; is how far the checkpoint has copied.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When &lt;code&gt;nBackfill&lt;/code&gt; reaches &lt;code&gt;mxFrame&lt;/code&gt;, everything in the WAL is in the database, and the next writer can start again at frame 1 instead of appending. It overwrites the old file from the start. It doesn't truncate it.&lt;/p&gt;

&lt;p&gt;By default SQLite runs a checkpoint after any commit that leaves the WAL at 1,000 frames or more (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/main.c#L2470-L2481" rel="noopener noreferrer"&gt;&lt;code&gt;sqlite3WalDefaultHook&lt;/code&gt;, &lt;code&gt;main.c&lt;/code&gt; L2470-L2481&lt;/a&gt;). That checkpoint is the PASSIVE kind: it never waits for anybody.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/sqlite-wal-never-shrinks/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What a reader pins
&lt;/h3&gt;

&lt;p&gt;A reader doesn't copy anything. When it starts a read transaction it pins one of four &lt;strong&gt;read marks&lt;/strong&gt; (&lt;code&gt;aReadMark[1..4]&lt;/code&gt;, since &lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L294-L299" rel="noopener noreferrer"&gt;&lt;code&gt;WAL_NREADER&lt;/code&gt;&lt;/a&gt; is 5 and slot 0 is special) whose value is no greater than the &lt;code&gt;mxFrame&lt;/code&gt; it saw. If a slot already holds that value it shares the slot with the readers already on it. Otherwise it writes its &lt;code&gt;mxFrame&lt;/code&gt; into a slot it can lock exclusively, and if it can't get one, it settles for the largest older mark (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L3163-L3199" rel="noopener noreferrer"&gt;&lt;code&gt;walTryBeginRead&lt;/code&gt;, L3163-L3199&lt;/a&gt;). It holds a shared lock on that slot until the transaction ends.&lt;/p&gt;

&lt;p&gt;Frames after its mark are newer than its snapshot. For pages changed in those frames, the reader still needs the old copy in the database file, so the checkpoint may not copy those frames over it until the reader finishes (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L378-L381" rel="noopener noreferrer"&gt;L378-L381&lt;/a&gt;). Frames up to its mark are safe to copy.&lt;/p&gt;

&lt;p&gt;If the WAL is fully copied when the reader starts, it takes slot 0 instead, which means "read the database file only, ignore the WAL" (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L3128-L3160" rel="noopener noreferrer"&gt;&lt;code&gt;walTryBeginRead&lt;/code&gt;, L3128-L3160&lt;/a&gt;). Remember that slot 0, it matters later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the checkpoint stops at the oldest reader
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;walCheckpoint&lt;/code&gt; starts by assuming it can copy everything, then walks the four read slots (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L2227-L2245" rel="noopener noreferrer"&gt;L2227-L2245&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;mxSafeFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pWal&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mxFrame&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;mxPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pWal&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nPage&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="n"&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="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WAL_NREADER&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;
  &lt;span class="n"&gt;u32&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AtomicLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pInfo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;aReadMark&lt;/span&gt;&lt;span class="o"&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;SEH_INJECT_FAULT&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="n"&gt;mxSafeFrame&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="p"&gt;){&lt;/span&gt;
    &lt;span class="n"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="n"&gt;pWal&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mxFrame&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;walBusyLock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pWal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xBusy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pBusyArg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WAL_READ_LOCK&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="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="n"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="n"&gt;SQLITE_OK&lt;/span&gt; &lt;span class="p"&gt;){&lt;/span&gt;
      &lt;span class="n"&gt;u32&lt;/span&gt; &lt;span class="n"&gt;iMark&lt;/span&gt; &lt;span class="o"&gt;=&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;mxSafeFrame&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;READMARK_NOT_USED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="n"&gt;AtomicStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pInfo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;aReadMark&lt;/span&gt;&lt;span class="o"&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;iMark&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;SEH_INJECT_FAULT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;walUnlockExclusive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pWal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WAL_READ_LOCK&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="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="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="n"&gt;SQLITE_BUSY&lt;/span&gt; &lt;span class="p"&gt;){&lt;/span&gt;
      &lt;span class="n"&gt;mxSafeFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;xBusy&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="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="k"&gt;goto&lt;/span&gt; &lt;span class="n"&gt;walcheckpoint_out&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;If it can lock a slot exclusively, nobody is using that mark, so it resets it: slot 1 to &lt;code&gt;mxSafeFrame&lt;/code&gt;, the others to &lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L401" rel="noopener noreferrer"&gt;&lt;code&gt;READMARK_NOT_USED&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;0xffffffff&lt;/code&gt;, unused). If it can't, a live reader is there, and &lt;code&gt;mxSafeFrame&lt;/code&gt; drops to that reader's mark. The copy loop then skips every frame after &lt;code&gt;mxSafeFrame&lt;/code&gt; (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L2306" rel="noopener noreferrer"&gt;L2306&lt;/a&gt;), and &lt;code&gt;nBackfill&lt;/code&gt; only advances to it (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L2331" rel="noopener noreferrer"&gt;L2331&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Then comes the line that makes this hard to notice (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L2340-L2345" rel="noopener noreferrer"&gt;L2340-L2345&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="n"&gt;SQLITE_BUSY&lt;/span&gt; &lt;span class="p"&gt;){&lt;/span&gt;
  &lt;span class="cm"&gt;/* Reset the return code so as not to report a checkpoint failure
  ** just because there are active readers.  */&lt;/span&gt;
  &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SQLITE_OK&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;So &lt;code&gt;PRAGMA wal_checkpoint(PASSIVE)&lt;/code&gt; returns &lt;code&gt;busy = 0&lt;/code&gt; even when it copied only part of the WAL. The signal is in the other two columns: &lt;code&gt;log&lt;/code&gt; (frames in the WAL) keeps getting bigger than &lt;code&gt;checkpointed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the file grows, and why it stays big
&lt;/h2&gt;

&lt;p&gt;Copying is only half of it. The WAL goes back to frame 1 in &lt;code&gt;walRestartLog&lt;/code&gt; (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L3869-L3909" rel="noopener noreferrer"&gt;L3869-L3909&lt;/a&gt;), which runs at the start of every write. It rewinds only if the writer's own snapshot is on slot 0 (&lt;code&gt;pWal-&amp;gt;readLock==0&lt;/code&gt;, so the WAL was fully copied when the writer started), and it can lock read slots 1 to 4 exclusively, meaning no reader is using the WAL.&lt;/p&gt;

&lt;p&gt;Now put a steady writer next to readers that overlap. Some reader always holds a mark from a second or so ago. The checkpoint copies up to that mark and stops. &lt;code&gt;nBackfill&lt;/code&gt; never catches up with &lt;code&gt;mxFrame&lt;/code&gt;, so the writer is never on slot 0, so it never rewinds. Every commit appends. The &lt;code&gt;-shm&lt;/code&gt; index grows with it, one 32 KiB block per 4,096 frames (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L615" rel="noopener noreferrer"&gt;&lt;code&gt;HASHTABLE_NPAGE&lt;/code&gt;, L615&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The SQLite docs describe exactly this under &lt;a href="https://www.sqlite.org/wal.html#bigwal" rel="noopener noreferrer"&gt;checkpoint starvation&lt;/a&gt;: "if a database has many concurrent overlapping readers and there is always at least one active reader, then no checkpoints will be able to complete and hence the WAL file will grow without bound."&lt;/p&gt;

&lt;p&gt;And when the overlap ends, the file keeps its size. A rewind moves the write position to frame 1, but, as the same page says, a checkpoint "does not normally truncate the WAL file (unless the journal_size_limit pragma is set)". The file only gets smaller when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;journal_size_limit&lt;/code&gt; is set, and the first commit after a rewind trims the file to that limit (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L4210-L4221" rel="noopener noreferrer"&gt;L4210-L4221&lt;/a&gt;). The default in this build is &lt;code&gt;-1&lt;/code&gt;, no limit.&lt;/li&gt;
&lt;li&gt;someone runs &lt;code&gt;wal_checkpoint(TRUNCATE)&lt;/code&gt;, which truncates it to 0 bytes (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L2363-L2381" rel="noopener noreferrer"&gt;L2363-L2381&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;the last connection to the database closes, checkpoints and deletes the WAL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;One writer process commits one row with a 1,000-byte blob per transaction, open loop at a fixed 2,000 commits per second, with &lt;code&gt;synchronous=NORMAL&lt;/code&gt;. Reader processes each run &lt;code&gt;BEGIN&lt;/code&gt;, a &lt;code&gt;SELECT&lt;/code&gt; that takes the snapshot, hold it, look up 100 recent rows, &lt;code&gt;COMMIT&lt;/code&gt;, then idle. A sampler records the file sizes every 100 ms. Seven scenarios, 30 s each, 3 runs of each, interleaved:&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;Scenario&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a&lt;/td&gt;
&lt;td&gt;writer only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;b&lt;/td&gt;
&lt;td&gt;2 readers holding 0.4 s every 1.0 s: about 20% of the time nobody is reading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;c&lt;/td&gt;
&lt;td&gt;3 readers holding 1.0 s, idling 0.2 s, staggered: at least 2 are always open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;d&lt;/td&gt;
&lt;td&gt;c, plus &lt;code&gt;wal_checkpoint(TRUNCATE)&lt;/code&gt; every 5 s with a 5 s busy timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;e&lt;/td&gt;
&lt;td&gt;c, plus &lt;code&gt;journal_size_limit&lt;/code&gt; of 4 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;f&lt;/td&gt;
&lt;td&gt;b, plus &lt;code&gt;journal_size_limit&lt;/code&gt; of 4 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;g&lt;/td&gt;
&lt;td&gt;c, with &lt;code&gt;wal_autocheckpoint=0&lt;/code&gt; on the writer (the sampler's once-a-second probe still checkpoints, see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is what the readers actually did in 3 seconds of one run of b and one of c, straight from the logged open and close times:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/sqlite-wal-never-shrinks/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And here is the WAL file:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/sqlite-wal-never-shrinks/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With gaps (b), the WAL rewinds twice a second, once per gap, 60 times in 30 s, and peaks at 6.05 to 6.08 MiB. With overlap (c), it never rewinds once, and grows in a straight line at 11.91 MiB/s in all three runs. That is about 1.5 frames of 4,120 bytes per commit: the final checkpoint counted 90,873 frames for 60,000 commits. At the end the &lt;code&gt;-shm&lt;/code&gt; file was 736 KiB, 23 index blocks.&lt;/p&gt;

&lt;p&gt;Through all of c, every PASSIVE probe returned &lt;code&gt;busy = 0&lt;/code&gt;, while &lt;code&gt;log&lt;/code&gt; ran up to 2,751 frames ahead of &lt;code&gt;checkpointed&lt;/code&gt;. That is a little under one second of writes, about one reader hold time.&lt;/p&gt;

&lt;h3&gt;
  
  
  After the readers leave
&lt;/h3&gt;

&lt;p&gt;When the run ended I stopped the readers and ran one more PASSIVE checkpoint with nothing else open. It copied everything, returned &lt;code&gt;0 | 90873 | 90873&lt;/code&gt;, and the file stayed where it was:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/sqlite-wal-never-shrinks/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;journal_size_limit&lt;/code&gt; did nothing in e. Its trim runs on the first commit after a rewind, and under overlap there are no rewinds. In f, where there are, it held the file at 4.00 MiB, though the high-water mark still reached 6.01 MiB between trims. In one of the three e runs the file was already 4.00 MiB before that final checkpoint. Most likely the readers closed a moment before the writer's last commits during shutdown, so one commit found a fully copied WAL, rewound and was trimmed: the same mechanism, with the blocker gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TRUNCATE costs
&lt;/h2&gt;

&lt;p&gt;The non-passive modes behave differently (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L4295-L4426" rel="noopener noreferrer"&gt;&lt;code&gt;sqlite3WalCheckpoint&lt;/code&gt;, L4295-L4426&lt;/a&gt;). FULL, RESTART and TRUNCATE take the writer lock first, waiting with the busy handler. Because &lt;code&gt;xBusy&lt;/code&gt; is set, &lt;code&gt;walBusyLock&lt;/code&gt; then waits on each reader slot instead of giving up. RESTART and TRUNCATE also wait until no reader is using the WAL at all (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L2358-L2362" rel="noopener noreferrer"&gt;L2358-L2362&lt;/a&gt;), and TRUNCATE then cuts the file to 0 bytes. Readers that start after the WAL is fully copied take slot 0, which doesn't hold the checkpoint up, and in my runs the readers' 100-row lookup was no slower in d than in c (226 against 224 µs median). The SQLite docs do warn that readers might block while it runs. The writer is blocked for sure, because the checkpoint holds its lock.&lt;/p&gt;

&lt;p&gt;In d, 14 of 15 TRUNCATE calls reset the WAL and returned &lt;code&gt;0 | 0 | 0&lt;/code&gt;. They took 1.40 to 1.81 s (median 1.70 s) with readers that hold for 1.0 s. That is close to two hold times, and the code explains why: first the call waits for the readers pinned below &lt;code&gt;mxFrame&lt;/code&gt;, but while it waits, readers that start see a WAL that isn't fully copied yet, so they take a real mark at the new &lt;code&gt;mxFrame&lt;/code&gt;. The RESTART step then has to wait for those too. On all 14 calls, the checkpoint returned within 105 ms of the last close of a reader that had started before the copy finished.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Median of 3 runs&lt;/th&gt;
&lt;th&gt;c: overlap&lt;/th&gt;
&lt;th&gt;d: c + TRUNCATE every 5 s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WAL at 30 s&lt;/td&gt;
&lt;td&gt;357.03 MiB&lt;/td&gt;
&lt;td&gt;59.58 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer time inside &lt;code&gt;execute()&lt;/code&gt;, p50 / p99.9&lt;/td&gt;
&lt;td&gt;38 / 2,653 µs&lt;/td&gt;
&lt;td&gt;38 / 745 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer response time p99&lt;/td&gt;
&lt;td&gt;0.50 ms&lt;/td&gt;
&lt;td&gt;1,719 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer response time max&lt;/td&gt;
&lt;td&gt;6.7 ms&lt;/td&gt;
&lt;td&gt;1,814 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commits more than 100 ms late (of 50,000)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;16,442&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Response time" is when a commit finished minus when the open-loop schedule said it should start, so a stall counts in full. Time inside &lt;code&gt;execute()&lt;/code&gt; hides it: only one commit per TRUNCATE actually waits, and the thousands queued behind it run fast once it lets go. If you measure the effect of a checkpoint job on your writer, measure response time.&lt;/p&gt;

&lt;p&gt;The one TRUNCATE that failed came back in 1.68 ms with &lt;code&gt;1 | -1 | -1&lt;/code&gt;. That is the &lt;code&gt;WAL_CKPT_LOCK&lt;/code&gt; check at the very start (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L4336" rel="noopener noreferrer"&gt;L4336&lt;/a&gt;): if another checkpoint is already running, every mode returns busy without calling the busy handler. My probe wasn't running at that moment, so the other checkpoint was almost certainly the writer's own autocheckpoint, which under starvation runs after every commit. That call missed, the WAL grew for another 5 s, and that run's high-water mark was 118.86 MiB instead of about 60.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two smaller results
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checkpointing on the writer's connection costs it something under starvation.&lt;/strong&gt; Once the WAL is past 1,000 frames, the autocheckpoint runs after every commit and copies whatever it can. In g the writer's autocheckpoint was off, and the only checkpoint was the sampler's PASSIVE probe once a second from another connection. That dropped the writer's p50 from 38 to 32 µs and its p99.9 from 2,653 to 694 µs. The WAL grew exactly as fast, and the probe kept &lt;code&gt;nBackfill&lt;/code&gt; as close as the autocheckpoint did (at most 2,743 frames behind, against 2,751 in c), since the checkpoint wasn't the thing holding the WAL down anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads did not get slower, and the code says they shouldn't.&lt;/strong&gt; A reader doesn't search the whole WAL index. &lt;code&gt;walFindFrame&lt;/code&gt; walks hash blocks only from its &lt;code&gt;mxFrame&lt;/code&gt; down to the block holding &lt;code&gt;minFrame&lt;/code&gt; (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L3568-L3569" rel="noopener noreferrer"&gt;L3568-L3569&lt;/a&gt;), and &lt;code&gt;minFrame&lt;/code&gt; is &lt;code&gt;nBackfill + 1&lt;/code&gt; when the read transaction starts (&lt;a href="https://github.com/sqlite/sqlite/blob/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c#L3253" rel="noopener noreferrer"&gt;L3253&lt;/a&gt;). So lookup cost depends on how far the checkpoint lags, not on the size of the WAL. Under starvation the lag stayed at most 2,751 frames, less than one 4,096-frame block, so a lookup searched 1 or 2 blocks. In c the median was 216 µs for transactions that started 5 to 12 s into the run and 218 µs for 22 to 30 s, at a WAL of up to 357 MiB. Reads would slow down only if &lt;code&gt;nBackfill&lt;/code&gt; itself fell far behind, for example with autocheckpoint off and no other checkpointer. I didn't measure that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do in your app
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep read transactions short.&lt;/strong&gt; The problem is overlap, and overlap comes from long snapshots: a cursor left open while you call an API, a &lt;code&gt;BEGIN&lt;/code&gt; that a connection pool never closed. In Python, a &lt;code&gt;SELECT&lt;/code&gt; whose rows you haven't fully fetched keeps its statement active, and an active statement keeps its read snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for it.&lt;/strong&gt; Poll &lt;code&gt;PRAGMA wal_checkpoint(PASSIVE)&lt;/code&gt; and alert when &lt;code&gt;log - checkpointed&lt;/code&gt; keeps rising, and watch the size of the &lt;code&gt;-wal&lt;/code&gt; file. The &lt;code&gt;busy&lt;/code&gt; column won't tell you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;journal_size_limit&lt;/code&gt;&lt;/strong&gt;, for example &lt;code&gt;PRAGMA journal_size_limit = 67108864&lt;/code&gt; for 64 MiB. It doesn't stop starvation, but it gives back the disk the next time the WAL rewinds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;wal_checkpoint(TRUNCATE)&lt;/code&gt; or &lt;code&gt;(RESTART)&lt;/code&gt; from a maintenance job&lt;/strong&gt; with a busy timeout, when writes are quiet. Expect it to block the writer for up to about twice your longest read transaction. If it returns &lt;code&gt;busy = 1&lt;/code&gt;, retry soon rather than waiting for the next period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you turn off autocheckpoint, checkpoint from another connection.&lt;/strong&gt; In g, one PASSIVE checkpoint a second from a separate connection kept up as well as the autocheckpoint and took that work off the writer. With nothing checkpointing at all, &lt;code&gt;nBackfill&lt;/code&gt; never reaches &lt;code&gt;mxFrame&lt;/code&gt;, so the WAL never rewinds, gaps or not.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machine:&lt;/strong&gt; Apple M5 Pro (18 cores, 48 GiB), macOS 26.6.2, APFS on the internal SSD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Software:&lt;/strong&gt; Python 3.14.4 and the SQLite it links, 3.53.2 (&lt;code&gt;DEFAULT_WAL_AUTOCHECKPOINT=1000&lt;/code&gt;, &lt;code&gt;DEFAULT_JOURNAL_SIZE_LIMIT=-1&lt;/code&gt;, from &lt;code&gt;PRAGMA compile_options&lt;/code&gt;). The source links above are pinned to the &lt;a href="https://github.com/sqlite/sqlite/tree/ccc132c5be20ab5c755c97a08d06b1b592fef330" rel="noopener noreferrer"&gt;&lt;code&gt;version-3.53.2&lt;/code&gt;&lt;/a&gt; tag, because that is what Python links here. 3.53.3 and 3.53.4 are out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs:&lt;/strong&gt; 7 scenarios × 3 runs × 30 s, interleaved. The first 5 s of each run are left out of the latency statistics. Tables show the median of 3 runs. The writer is open loop, so every run issued all 60,000 scheduled commits, and all of them completed with no write errors. That says nothing about stalls; the response times above do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caveats:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;The sampler's PASSIVE probe once a second is itself a checkpoint, and it runs in every scenario. Where autocheckpoint is on, it copies no more than the autocheckpoint would. In g it is the only checkpointer, so g means "one PASSIVE checkpoint a second from another connection", not "no checkpointing". &lt;code&gt;--probe-every 0&lt;/code&gt; turns it off.&lt;/li&gt;
&lt;li&gt;The first full pass had a probe colliding with the TRUNCATE calls and a maintenance call firing after the run had ended. I fixed both and reran everything. All numbers here are from the second pass.&lt;/li&gt;
&lt;li&gt;This is one machine and one filesystem. I did not run the Linux cross-check or a sweep of write rates.&lt;/li&gt;
&lt;li&gt;"MiB" is 2^20 bytes.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scripts, the raw CSVs and how to rerun them are in the &lt;a href="https://getnobody.org/labs/sqlite-wal-starvation/" rel="noopener noreferrer"&gt;lab folder&lt;/a&gt;. &lt;code&gt;python3 run.py&lt;/code&gt; runs the full matrix in about 12 minutes with nothing but the standard library.&lt;/p&gt;

&lt;p&gt;Credit goes to D. Richard Hipp and the &lt;a href="https://sqlite.org/" rel="noopener noreferrer"&gt;SQLite&lt;/a&gt; developers, who wrote and maintain the &lt;a href="https://github.com/sqlite/sqlite/commits/ccc132c5be20ab5c755c97a08d06b1b592fef330/src/wal.c" rel="noopener noreferrer"&gt;WAL code&lt;/a&gt; and whose &lt;a href="https://www.sqlite.org/wal.html" rel="noopener noreferrer"&gt;WAL documentation&lt;/a&gt; already describes this failure and the "reader gaps" fix in plain words. The code is commented well enough that a stranger can follow a checkpoint line by line, which is how this post got written. The same page also documents this year's &lt;a href="https://www.sqlite.org/wal.html#walresetbug" rel="noopener noreferrer"&gt;WAL-reset bug&lt;/a&gt;, a rare corruption case when two connections write or checkpoint at the same instant, which one of the SQLite developers, Dan, found and fixed in 3.51.3.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>database</category>
      <category>internals</category>
      <category>benchmarks</category>
    </item>
    <item>
      <title>How Postgres 19 checks foreign keys without running SQL</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Thu, 24 Sep 2026 23:29:54 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/how-postgres-19-checks-foreign-keys-without-running-sql-4f1h</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/how-postgres-19-checks-foreign-keys-without-running-sql-4f1h</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/postgres-19-foreign-key-fast-path/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every time you insert a row into a table with a foreign key, Postgres has to prove the referenced row exists and stop anyone from deleting it until your transaction ends. Up to Postgres 18, it did that by running a tiny SQL query for every single row. Postgres 19 stops doing that. I read the new code, then measured it.&lt;/p&gt;

&lt;p&gt;The short version: on a 1 million row bulk insert, the foreign key check costs about &lt;strong&gt;44% less time&lt;/strong&gt; in Postgres 19. A second optimization that was pulled from 19 before release would have helped much more, but only when the foreign key values arrive in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old path: a query per row
&lt;/h2&gt;

&lt;p&gt;A foreign key is enforced by an internal &lt;code&gt;AFTER INSERT&lt;/code&gt; trigger, &lt;code&gt;RI_FKey_check&lt;/code&gt; in &lt;a href="https://github.com/postgres/postgres/blob/8fbbb338bed77c6fa8dc6fa309cbcca93c1c2d72/src/backend/utils/adt/ri_triggers.c" rel="noopener noreferrer"&gt;&lt;code&gt;ri_triggers.c&lt;/code&gt;&lt;/a&gt;. In Postgres 18 that trigger builds this query once, saves the plan, and runs it through SPI (the server's internal SQL interface) for every inserted row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;ONLY&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;SHARE&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FOR KEY SHARE&lt;/code&gt; is the important part. It locks the parent row so nobody can delete it or change its key while your transaction is open, but it still lets other sessions update the parent's non-key columns.&lt;/p&gt;

&lt;p&gt;The plan is cached, but every row still pays for SPI setup, executor startup and teardown, snapshot handling and the permission checks, just to do one index lookup.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/postgres-19-foreign-key-fast-path/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The new path: probe the index directly
&lt;/h2&gt;

&lt;p&gt;Amit Langote's &lt;a href="https://github.com/postgres/postgres/commit/2da86c1ef9b5446e0e22c0b6a5846293e58d98e3" rel="noopener noreferrer"&gt;commit 2da86c1ef9&lt;/a&gt; adds a fast path. Before touching SPI, &lt;code&gt;RI_FKey_check&lt;/code&gt; now asks two questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ri_fastpath_is_applicable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;riinfo&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="n"&gt;ri_FastPathCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;riinfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fk_rel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newslot&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;PointerGetDatum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&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;&lt;code&gt;ri_fastpath_is_applicable&lt;/code&gt; says no for two cases: when the &lt;strong&gt;referenced table is partitioned&lt;/strong&gt; (the probe would need to be routed to the right partition), and for &lt;strong&gt;temporal foreign keys&lt;/strong&gt; (range containment needs more than a single lookup). Later fixes added more fallbacks: the referenced index has to be a btree, and its collation must match the column's.&lt;/p&gt;

&lt;p&gt;If the answer is yes, &lt;code&gt;ri_FastPathCheck&lt;/code&gt; does what the query used to do, by hand:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opens the parent table and its unique index.&lt;/li&gt;
&lt;li&gt;Takes a snapshot. In the shipped version this happens &lt;em&gt;after&lt;/em&gt; the table lock is granted. Taking it earlier opened a window where a parent row committed while you waited for the lock would be invisible, and the check would fail for a key that exists (&lt;a href="https://github.com/postgres/postgres/commit/1390182683" rel="noopener noreferrer"&gt;commit 1390182683&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Builds scan keys from the new row's values and probes the btree with &lt;code&gt;index_getnext_slot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Locks the row it found with &lt;code&gt;table_tuple_lock(..., LockTupleKeyShare, ...)&lt;/code&gt; in &lt;code&gt;ri_LockPKTuple&lt;/code&gt;. That's the same lock &lt;code&gt;FOR KEY SHARE&lt;/code&gt; takes.&lt;/li&gt;
&lt;li&gt;If the lock had to follow an update chain to reach the newest row version, &lt;code&gt;recheck_matched_pk_tuple&lt;/code&gt; checks that the key still matches. That is the fast path's version of the recheck the executor does for &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It also switches to the parent table owner's user id for the lookup, the same way the SPI path does, so permissions and row-level security behave the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;A parent table with 1 million integer primary keys, and a child table with a foreign key to it. Each run inserts 1 million child rows in one &lt;code&gt;INSERT ... SELECT&lt;/code&gt;, after a &lt;code&gt;TRUNCATE&lt;/code&gt; and a &lt;code&gt;CHECKPOINT&lt;/code&gt;. The same insert into a table with no foreign key is the baseline. Six runs per version; the charts show the median.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/postgres-19-foreign-key-fast-path/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With scattered keys, the foreign key check itself (the time above the no-foreign-key baseline) drops from &lt;strong&gt;2.17 s in 18.6 to 1.21 s in 19&lt;/strong&gt;. The whole insert is 1.66x faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  The control: turn the fast path off
&lt;/h3&gt;

&lt;p&gt;To check the gain really comes from the fast path, I ran the same test with a partitioned parent table. The fast path refuses partitioned parents, so 19 falls back to SPI:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/postgres-19-foreign-key-fast-path/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things show up here. The gain disappears when the fast path is off, so the fast path is what made the difference. And a foreign key to a partitioned table is expensive either way: about 3.5x slower than the SPI path on a plain table, because every check has to find the right partition first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The batching that was pulled
&lt;/h2&gt;

&lt;p&gt;Three days after the fast path, a &lt;a href="https://github.com/postgres/postgres/commit/b7b27eb41a5cc0b45a1a9ce5c1cde5883d7bc358" rel="noopener noreferrer"&gt;second commit&lt;/a&gt; went further. Instead of probing the index once per row, it buffered up to 64 rows and checked them together. For single-column keys it passed all 64 values as one array scan key (&lt;code&gt;SK_SEARCHARRAY&lt;/code&gt;), so the btree sorts them and walks its leaf pages once instead of descending from the root 64 times. The commit reported about 2.9x in total over the old code.&lt;/p&gt;

&lt;p&gt;That batching is in the 19 betas but &lt;strong&gt;not in 19.0&lt;/strong&gt;. On September 10 it was &lt;a href="https://github.com/postgres/postgres/commit/25649d6e791c2d803fff0ce8b69ed968028a5edc" rel="noopener noreferrer"&gt;removed from REL_19_STABLE&lt;/a&gt;. Buffered checks have to survive across trigger calls and be flushed at the right moment, and that state has to be right under nested triggers, subtransactions, deferred constraints and &lt;code&gt;SET CONSTRAINTS&lt;/code&gt;. Miss one case and a buffered check never runs: the transaction commits a real foreign key violation without an error. The commit message says it plainly: with 19 close to release, there wasn't time to be sure every case was covered. Batching stays on master for a later release. That's a hard call to make that close to release, and it's the right one.&lt;/p&gt;

&lt;p&gt;My first measurement disagreed with the 2.9x. With scattered keys, master (batching included) is only about 2% faster than 19. The array scan only saves work when the 64 values land on the same few leaf pages. My keys were deliberately spread over the whole index, so every value still needed its own leaf.&lt;/p&gt;

&lt;p&gt;So I reran with the keys in order (&lt;code&gt;parent_id = 1, 2, 3, ...&lt;/code&gt;), which is what you get when child rows are loaded in parent order. Now batching does what the commit said: &lt;strong&gt;701 ms against 1,021 ms&lt;/strong&gt; for 19, and 2.8x against 18.6, close to the reported 2.9x.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keys&lt;/th&gt;
&lt;th&gt;18.6&lt;/th&gt;
&lt;th&gt;19&lt;/th&gt;
&lt;th&gt;master (batching)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scattered&lt;/td&gt;
&lt;td&gt;2,499 ms&lt;/td&gt;
&lt;td&gt;1,509 ms&lt;/td&gt;
&lt;td&gt;1,473 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequential&lt;/td&gt;
&lt;td&gt;1,965 ms&lt;/td&gt;
&lt;td&gt;1,021 ms&lt;/td&gt;
&lt;td&gt;701 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scattered, partitioned parent&lt;/td&gt;
&lt;td&gt;7,808 ms&lt;/td&gt;
&lt;td&gt;7,395 ms&lt;/td&gt;
&lt;td&gt;not run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this means if you run Postgres
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk loads into tables with foreign keys get faster in 19 for free&lt;/strong&gt;, as long as the referenced table isn't partitioned and the key has a btree index (primary keys and unique constraints do).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign keys to partitioned tables don't get this.&lt;/strong&gt; If you bulk-load into a table that references a partitioned table, the check still costs about 7 µs per row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load order matters more after batching lands.&lt;/strong&gt; Once batching ships in a later release, loading child rows sorted by parent key could make the check nearly twice as cheap again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the insert-side check changed.&lt;/strong&gt; &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, &lt;code&gt;SET NULL&lt;/code&gt; and the other action triggers still go through SPI, as the original commit explains.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machine:&lt;/strong&gt; Apple M-series laptop. Each server ran in Docker (a Linux arm64 VM) with 4 CPUs and 4 GB of memory, one server at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings:&lt;/strong&gt; &lt;code&gt;shared_buffers = 1GB&lt;/code&gt;, &lt;code&gt;max_wal_size = 8GB&lt;/code&gt; and &lt;code&gt;checkpoint_timeout = 30min&lt;/code&gt; on every version. Everything else was left at the defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;18.6 and 19beta3 are the official Docker images.&lt;/li&gt;
&lt;li&gt;"19" is REL_19_STABLE at &lt;a href="https://github.com/postgres/postgres/commit/8fbbb338bed77c6fa8dc6fa309cbcca93c1c2d72" rel="noopener noreferrer"&gt;8fbbb338be&lt;/a&gt;, which already has batching removed, built from source with &lt;code&gt;-O2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;"master" is master at &lt;a href="https://github.com/postgres/postgres/commit/4545cee" rel="noopener noreferrer"&gt;4545cee&lt;/a&gt;, built the same way.&lt;/li&gt;
&lt;li&gt;The 19beta3 image still includes batching and measured 1,529 ms on scattered keys, close to both 19 and master.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caveats:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;The source builds use a different compiler setup from the packaged images. That probably explains part of the small difference in the no-foreign-key baseline (332 ms against 299 ms).&lt;/li&gt;
&lt;li&gt;These are single-session numbers. Concurrent inserts and lock contention are a separate test.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All scripts, Dockerfiles and raw results are in the &lt;a href="https://getnobody.org/labs/pg19-fk-fastpath/" rel="noopener noreferrer"&gt;lab folder&lt;/a&gt;. Run &lt;code&gt;./run.sh &amp;lt;label&amp;gt; &amp;lt;image&amp;gt;&lt;/code&gt; and you get the same table.&lt;/p&gt;

&lt;p&gt;Credit goes to Amit Langote, who wrote the fast path, the batching and roughly thirty follow-up commits to this code this year, and to everyone on pgsql-hackers who tested the betas hard enough to find the problems before release.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>internals</category>
      <category>benchmarks</category>
    </item>
    <item>
      <title>Why easing makes animation feel alive</title>
      <dc:creator>Nobody</dc:creator>
      <pubDate>Thu, 24 Sep 2026 04:27:25 +0000</pubDate>
      <link>https://dev.to/nobody_4fb7fd10637d3b88a3/why-easing-makes-animation-feel-alive-4g3f</link>
      <guid>https://dev.to/nobody_4fb7fd10637d3b88a3/why-easing-makes-animation-feel-alive-4g3f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://getnobody.org/blog/easing-makes-animation-feel-alive/" rel="noopener noreferrer"&gt;getnobody.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Move a box from left to right at a constant speed and it looks mechanical, like a machine part. Let it start quickly and settle gently into place and it suddenly feels like an object with weight. The only thing that changed is the &lt;strong&gt;easing curve&lt;/strong&gt;: how progress is spread out over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progress versus time
&lt;/h2&gt;

&lt;p&gt;Every animation has a &lt;strong&gt;progress&lt;/strong&gt; value that goes from 0 (start) to 1 (end). Easing is a function that takes linear time and returns how far along the motion should be.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/easing-makes-animation-feel-alive/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Curve&lt;/th&gt;
&lt;th&gt;Formula (p = time from 0 to 1)&lt;/th&gt;
&lt;th&gt;Feels like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Linear&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A machine. Constant speed, abrupt start and stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ease in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;p³&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Something gathering speed, like falling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ease out&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1 - (1 - p)³&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Something arriving and settling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ease in-out&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;4p³&lt;/code&gt; for the first half, then &lt;code&gt;1 - (-2p + 2)³ / 2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A deliberate move from A to B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one to use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Things entering the screen: ease out.&lt;/strong&gt; They arrive fast and settle. This is the most useful curve by far, because the viewer's eye catches the movement early and the element is readable sooner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Things leaving the screen: ease in.&lt;/strong&gt; They start slowly and accelerate away, so they don't distract at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Things moving from one place to another on screen: ease in-out.&lt;/strong&gt; Both ends are gentle, so the move reads as intentional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linear: almost never for movement.&lt;/strong&gt; It works for things that should feel mechanical or continuous: a progress bar filling, a clock hand, a line being drawn at a steady pace.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Timing matters as much as the curve
&lt;/h2&gt;

&lt;p&gt;The same curve feels different depending on how long it lasts and how elements are staggered:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Chart: see the &lt;a href="https://getnobody.org/blog/easing-makes-animation-feel-alive/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A few rules of thumb from building animated explainers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stagger entrances by 150 to 400 ms&lt;/strong&gt;, in reading order. Everything appearing at once reads as a flash; one thing at a time reads as a story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep small entrances short&lt;/strong&gt;, around 300 to 600 ms. Longer feels sluggish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time motion to speech.&lt;/strong&gt; In a narrated video, an element should appear just as the narrator mentions it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Combine properties.&lt;/strong&gt; A fade plus a small 20 to 40 pixel rise looks far more polished than a fade alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  In code
&lt;/h2&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;EASING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;p&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;easeIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;p&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;easeOut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;p&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;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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="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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;easeInOut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;p&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;p&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="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="o"&gt;*&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;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;3&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="c1"&gt;// Where an element is at time t, for a move from `from` to `to`:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;EASING&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;easeOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are cubic curves. CSS offers similar built-in curves (&lt;code&gt;ease-in&lt;/code&gt;, &lt;code&gt;ease-out&lt;/code&gt;, &lt;code&gt;ease-in-out&lt;/code&gt;) defined with &lt;code&gt;cubic-bezier()&lt;/code&gt;, whose exact shapes differ slightly but follow the same idea.&lt;/p&gt;

&lt;p&gt;Earlier in this series: &lt;a href="https://dev.to/blog/how-a-video-editor-works/"&gt;how a video editor works&lt;/a&gt; and &lt;a href="https://dev.to/blog/render-mp4-in-the-browser/"&gt;rendering MP4 video in the browser&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Nobody&lt;/em&gt;&lt;/p&gt;

</description>
      <category>animation</category>
      <category>design</category>
      <category>explainer</category>
    </item>
  </channel>
</rss>
