<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[FindUtils Blog]]></title><description><![CDATA[FindUtils Blog]]></description><link>https://findutils.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 00:16:49 GMT</lastBuildDate><atom:link href="https://findutils.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[YAML to TOML to .env: Five Values That Changed Without a Single Error]]></title><description><![CDATA[Five YAML parsers, a TOML writer, six .env loaders: NO became false, 1:30 became 90, 0755 became 493. Two diffs catch it.]]></description><link>https://findutils.hashnode.dev/yaml-to-toml-to-env-five-values-that-changed-without-a-single-error</link><guid isPermaLink="true">https://findutils.hashnode.dev/yaml-to-toml-to-env-five-values-that-changed-without-a-single-error</guid><category><![CDATA[YAML]]></category><category><![CDATA[TOML]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Python]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Wed, 28 Oct 2026 06:46:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/4ce543ba-ed50-41be-b185-1963cc41a5a4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Converting a config file feels like a formatting job. You change the syntax, check that the keys are all there, and ship it. Most of the time nothing breaks, which is exactly why the time it does break is so confusing: there's no error, and the file looks fine. It just means something different now.</p>
<p>I wanted to see how much can change in an ordinary conversion, so I wrote one small, realistic config and pushed it through the tools people actually use: five YAML parsers (Python, JavaScript and Go), a TOML writer, and six <code>.env</code> loaders. Everything below was run on a Linux box this week. The outputs are copied from the terminal, not reconstructed from documentation.</p>
<h2>The test file</h2>
<p>This is a fictional shop service. Every line is something I've seen in real configs:</p>
<pre><code class="language-yaml"># shop service config (fictional)
app:
  name: shop
  version: 1.10
  country: NO
  debug: off
  launch_date: 2026-01-01
  timeout: 1:30
  file_mode: 0755
  zip: 01234
  port: "8080"
  sentry_dsn: ~
defaults: &amp;defaults
  retries: 3
  region: eu-west-1
worker:
  &lt;&lt;: *defaults
  queue: emails
</code></pre>
<p>What a human means: version "1.10", Norway, debug turned off, a launch date, a timeout of one minute thirty, Unix permissions 0755, a ZIP code with a leading zero, port 8080, and no Sentry DSN yet.</p>
<h2>Step 1: what each YAML parser actually read</h2>
<p>I loaded the file with PyYAML 6.0.3, ruamel.yaml 0.19.1, js-yaml 4.3.2, <code>yaml</code> 2.9.1 (default mode and <code>version: '1.1'</code>) and Go's <code>gopkg.in/yaml.v3</code> v3.0.1, then printed each value with its type.</p>
<p>PyYAML follows YAML 1.1:</p>
<pre><code>PyYAML 6.0.3
  version      1.1                          float
  country      False                        bool
  debug        False                        bool
  launch_date  datetime.date(2026, 1, 1)    date
  timeout      90                           int
  file_mode    493                          int
  zip          668                          int
  port         '8080'                       str
  sentry_dsn   None                         NoneType
</code></pre>
<p>ruamel.yaml follows YAML 1.2:</p>
<pre><code>ruamel.yaml 0.19.1
  version      1.1                          float
  country      'NO'                         str
  debug        'off'                        str
  timeout      '1:30'                       str
  file_mode    755                          int
  zip          1234                         int
</code></pre>
<p>Put side by side, the same file gives these results:</p>
<ul>
<li><strong>country: NO.</strong> It is <code>False</code> in PyYAML and in <code>yaml</code> with version 1.1. It is <code>"NO"</code> in ruamel, js-yaml, <code>yaml</code> default mode and Go yaml.v3.</li>
<li><strong>debug: off.</strong> It is <code>False</code> under 1.1 rules and the string <code>"off"</code> under 1.2. If your app checks <code>if config.debug:</code>, the string <code>"off"</code> is truthy, so debug is <em>on</em>.</li>
<li><strong>timeout: 1:30.</strong> PyYAML reads this as a base-60 number: <code>90</code>. The 1.2 parsers keep the string <code>"1:30"</code>.</li>
<li><strong>file_mode: 0755.</strong> It is <code>493</code> (octal) in PyYAML, in <code>yaml</code> 1.1 mode and also in Go yaml.v3. It is <code>755</code> (decimal) in ruamel, js-yaml and <code>yaml</code> default. That's three different meanings of the same four characters across popular libraries.</li>
<li><strong>zip: 01234.</strong> It is <code>668</code> (octal again) in PyYAML and Go, and <code>1234</code> everywhere else. <strong>No parser kept the leading zero.</strong></li>
<li><strong>version: 1.10.</strong> It is <code>1.1</code> in every single parser. It's a float, so the trailing zero is gone before conversion even starts.</li>
<li><strong>launch_date.</strong> It is a real date in Python, a <code>time.Time</code> in Go, a JavaScript <code>Date</code> (midnight UTC) in js-yaml, and the plain string <code>"2026-01-01"</code> in <code>yaml</code> default mode.</li>
<li><strong>port: "8080".</strong> It's quoted, so it's a string everywhere. Quoting was the only thing that behaved the same in all of them.</li>
</ul>
<p>The merge key (<code>&lt;&lt;: *defaults</code>) was expanded by all five, so <code>worker</code> ended up with <code>retries</code>, <code>region</code> and <code>queue</code>. That's good to know, but it also means the anchor is gone after conversion. If you edit <code>defaults</code> later, the TOML copy won't follow.</p>
<p><strong>Lesson one:</strong> the conversion bug is often already in the parse step. By the time any converter sees your data, <code>NO</code> may already be <code>false</code>.</p>
<h2>Step 2: writing TOML</h2>
<p>Next I passed both parsed results to <code>tomli_w</code> 1.2.0, a strict TOML writer:</p>
<pre><code>tomli_w.dumps as-is -&gt; TypeError: Object of type 'NoneType' is not TOML serializable
</code></pre>
<p>TOML has no null. That's the one loud failure in this whole experiment, and it's a useful one. You have to decide what <code>sentry_dsn: ~</code> should become: drop the key, use an empty string, or comment it out. Each choice means something different to the app reading it. "Key missing" and "key set to empty" often take different code paths.</p>
<p>After dropping the null, the PyYAML path produced:</p>
<pre><code class="language-toml">[app]
name = "shop"
version = 1.1
country = false
debug = false
launch_date = 2026-01-01
timeout = 90
file_mode = 493
zip = 668
port = "8080"
</code></pre>
<p>The ruamel path produced the same file except <code>country = "NO"</code>, <code>debug = "off"</code>, <code>timeout = "1:30"</code>, <code>file_mode = 755</code> and <code>zip = 1234</code>. Both wrote the date as a real TOML local date, and reading it back with Python's <code>tomllib</code> returned <code>datetime.date(2026, 1, 1)</code>.</p>
<p>Both files are valid TOML, and both are wrong in different places. Nothing warned me.</p>
<h2>Step 3: diff the two TOML files</h2>
<p>This is the step that actually catches it. I pasted the ruamel output as file A and the PyYAML output as file B into the <a href="https://findutils.com/developers/toml-diff/">FindUtils TOML Diff</a> and pressed Compare. The summary read <strong>Added 0, Removed 0, Changed 5, Unchanged 9</strong>:</p>
<pre><code>app.country    "NO"   -&gt; false
app.debug      "off"  -&gt; false
app.timeout    "1:30" -&gt; 90
app.file_mode  755    -&gt; 493
app.zip        1234   -&gt; 668
</code></pre>
<p>That's exactly the five values the YAML version changed, and nothing else. A text diff of the same two files would have flagged the lines too, but it can't tell you whether <code>"NO"</code> → <code>false</code> is a type change or a value change, and it gets noisy as soon as the key order or whitespace differs. A structural diff compares parsed values by path, which is the question you actually care about after a conversion.</p>
<p>If you want the walkthrough, there's a <a href="https://findutils.com/guides/toml-diff/">TOML Diff guide</a>.</p>
<p>My routine now: <strong>convert with the parser you'll run in production, convert again with a different one, and diff the two.</strong> Where they disagree, quote the value in the source YAML.</p>
<h2>Step 4: flattening to .env, where quoting is a dialect</h2>
<p>The last hop in a lot of pipelines is <code>.env</code>: Docker, Laravel, Node, Go services, CI variables. People talk about "the .env format" as if there were one. I wrote eight one-line files, each setting a single variable <code>V</code>, and loaded them with six loaders: python-dotenv 1.2.4, Node <code>dotenv</code> 17.4.2 (alone, and with <code>dotenv-expand</code> 12.0.3), Go <code>godotenv</code> 1.5.1, <code>vlucas/phpdotenv</code> 5.8.0 (the loader Laravel uses), and plain bash <code>set -a; . ./file</code>. <code>HOME_DIR=/srv</code> was set for the expansion cases.</p>
<p>Here are the results:</p>
<p><strong>Case 1, unquoted <code>V=p@ss$word#1</code>:</strong></p>
<ul>
<li><code>p@ss$word#1</code> in python-dotenv and godotenv</li>
<li><code>p@ss$word</code> in Node dotenv and phpdotenv (the <code>#1</code> was read as a comment)</li>
<li><code>p@ss</code> in Node with dotenv-expand (<code>$word</code> expanded to nothing)</li>
<li><code>p@ss#1</code> in bash</li>
</ul>
<p>That's one password and four different values. Your app would fail to log in with four of the six loaders.</p>
<p><strong>Case 2, double-quoted <code>V="p@ss$word#1"</code>:</strong> the full value in python-dotenv, Node dotenv, godotenv and phpdotenv. dotenv-expand and bash still expand <code>$word</code> and give <code>p@ss#1</code>.</p>
<p><strong>Case 3, single-quoted <code>V='p@ss$word#1'</code>:</strong> the full value in every loader <em>except</em> Node with dotenv-expand, which returned <code>p@ss#1</code>. Single quotes protected the value from bash, but not from dotenv-expand. This one surprised me the most.</p>
<p><strong>Case 4, <code>V=secret #1</code>:</strong> <code>secret</code> everywhere. A space before <code>#</code> starts a comment in all six.</p>
<p><strong>Case 5, <code>V=hello world</code>:</strong> <code>hello world</code> in four loaders. phpdotenv refused the file with <code>Encountered unexpected whitespace at [hello world]</code>, and bash tried to run <code>world</code> as a command.</p>
<p><strong>Case 6, <code>V=${HOME_DIR}/data</code>:</strong> <code>/srv/data</code> everywhere except plain Node dotenv, which keeps it literal unless you add dotenv-expand.</p>
<p><strong>Case 7, <code>V=$HOME_DIR/data</code> (no braces):</strong> this is expanded by godotenv, dotenv-expand and bash, but left as the literal <code>$HOME_DIR/data</code> by python-dotenv and phpdotenv. If you write the variable without braces, Python and PHP services won't expand it.</p>
<p><strong>Case 8, <code>V="line1\nline2"</code>:</strong> a real newline in all five dotenv libraries, and a literal backslash-n in bash.</p>
<p><strong>What I do now, based on these results:</strong></p>
<ol>
<li>Double-quote any value that contains <code>$</code>, <code>#</code>, spaces or a backslash.</li>
<li>If your stack runs dotenv-expand, a literal <code>$</code> in a secret needs escaping even inside single quotes. Better still, generate secrets without <code>$</code>.</li>
<li>Always write <code>${VAR}</code> with braces if you want expansion. Never rely on bare <code>$VAR</code>.</li>
<li>Don't <code>source</code> a <code>.env</code> file in a shell script and expect it to match what your app sees.</li>
</ol>
<h2>Step 5: diff the .env files, not just the YAML</h2>
<p>Once there's a <code>.env</code> on each side, say <code>.env.example</code> and the generated <code>.env</code>, compare them by parsed value, not by text. I pasted these two files into the <a href="https://findutils.com/developers/env-diff/">FindUtils Env Diff</a>:</p>
<pre><code class="language-bash"># File A
APP_NAME="Shop"
APP_VERSION=1.10
DB_PASSWORD=p@ss$word#1
SMTP_FROM=shop@example.com #ops
QUEUE=emails
</code></pre>
<pre><code class="language-bash"># File B
APP_NAME='Shop'
APP_VERSION=1.1
DB_PASSWORD="p@ss$word#1"
SMTP_FROM="shop@example.com"
REGION=eu-west-1
</code></pre>
<p>It reported <strong>1 only in B (REGION), 1 only in A (QUEUE), 1 changed (APP_VERSION 1.10 → 1.1) and 3 unchanged</strong>. The differences in quoting didn't count as changes. <code>"Shop"</code> and <code>'Shop'</code> are equal, and <code>SMTP_FROM</code> with a trailing <code> #ops</code> comment equals the quoted address. Its page says the same thing: values are compared after dotenv rules are applied, and it doesn't expand <code>${VAR}</code> references. Secret-looking keys such as <code>DB_PASSWORD</code> are masked by default, so you can paste a real file and share the report.</p>
<p>One caveat that matches the table above: the tool treats the unquoted <code>DB_PASSWORD=p@ss$word#1</code> as equal to the quoted one, which is how python-dotenv and godotenv read it. Node dotenv and phpdotenv would cut it at the <code>#</code>. So a clean Env Diff tells you that both files <em>say</em> the same thing. It can't promise every loader <em>reads</em> it the same way. That's why rule 1, quote anything with <code>#</code> or <code>$</code>, is still worth following. See the <a href="https://findutils.com/guides/env-diff/">Env Diff guide</a> for the options (masking, ignore key case, duplicate keys).</p>
<h2>A checklist you can paste into a PR</h2>
<p>FindUtils already has a longer <a href="https://findutils.com/blog/config-conversion-review-checklist/">config conversion review checklist</a> and a piece on <a href="https://findutils.com/blog/config-format-conversion-what-survives/">what survives a config format conversion</a>. Here is the short version, shaped by what this experiment broke:</p>
<ul>
<li><strong>Quote in the source.</strong> <code>"NO"</code>, <code>"off"</code>, <code>"1:30"</code>, <code>"0755"</code>, <code>"01234"</code>, <code>"1.10"</code>. If a value is an identifier, a code, a version or a mode, it is a string.</li>
<li><strong>Know your parser's YAML version.</strong> PyYAML is 1.1. Go yaml.v3 reads <code>0755</code> as octal. js-yaml and ruamel are 1.2.</li>
<li><strong>Decide what null becomes</strong> before the TOML writer decides for you, or crashes.</li>
<li><strong>Check dates.</strong> Is it a date, a midnight-UTC timestamp, or a string? Each one serializes differently downstream.</li>
<li><strong>Anchors are expanded on conversion.</strong> The TOML or <code>.env</code> copy is a snapshot.</li>
<li><strong>Run two parsers and diff the results</strong> with TOML Diff. Anything in "Changed" needs a quote or a comment.</li>
<li><strong>Diff <code>.env</code> files by value</strong> with Env Diff before deploying, and quote anything containing <code>$</code>, <code>#</code>, spaces or <code>\</code>.</li>
</ul>
<h2>Reproduce it</h2>
<p>The setup is small: one YAML file, one script per language, and a folder of eight <code>.env</code> files. The versions I used were PyYAML 6.0.3, ruamel.yaml 0.19.1, tomli_w 1.2.0, python-dotenv 1.2.4, js-yaml 4.3.2, yaml 2.9.1, dotenv 17.4.2, dotenv-expand 12.0.3, gopkg.in/yaml.v3 v3.0.1, godotenv 1.5.1 and vlucas/phpdotenv 5.8.0, with Node 20, Go 1.27 and PHP 8.4. Pin your own versions. YAML libraries change their defaults more often than you'd expect.</p>
<p>The point isn't that any one library is wrong. Each one does what its spec or its README says. The problem is that a config passes through three or four of them on the way to production, and every hop can quietly change a meaning without an error. Two diffs take a minute and catch all of it.</p>
<hr />
<p>Need help with a project? The team behind FindUtils takes on freelance work: <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">hire us on Upwork</a>. If your Laravel app needs an admin panel whose settings screens read and write config safely, see our <a href="https://www.upwork.com/services/product/development-it-a-laravel-livewire-app-or-admin-panel-with-dynamic-pages-and-forms-2112447485311211142">Laravel Livewire admin panel service</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Why Your Countdown Falls Behind in a Background Tab: setInterval, Chrome's Timer Throttling and a Timer That Keeps Time]]></title><description><![CDATA[setInterval countdowns lost 30–54 s in 8 hidden minutes in Chrome 156; a clock-based one kept time. A one-setTimeout alarm fired 0.8 s late.]]></description><link>https://findutils.hashnode.dev/why-your-countdown-falls-behind-in-a-background-tab-setinterval-chrome-s-timer-throttling-and-a-timer-that-keeps-time</link><guid isPermaLink="true">https://findutils.hashnode.dev/why-your-countdown-falls-behind-in-a-background-tab-setinterval-chrome-s-timer-throttling-and-a-timer-that-keeps-time</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Chrome]]></category><category><![CDATA[frontend]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Tue, 27 Oct 2026 13:26:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/75313a50-60c3-4a34-be58-077ba3fc26d4.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Almost every countdown on the web starts like this:</p>
<pre><code class="language-js">let remaining = 25 * 60;
setInterval(() =&gt; {
  remaining--;
  render(remaining);
}, 1000);
</code></pre>
<p>It looks right on screen. Then someone starts a 25-minute focus session, switches to their editor, comes back, and the timer still shows several minutes to go after the real 25 minutes have passed. The code assumes the callback runs once per second. The browser never promised that, and in a background tab it often doesn't.</p>
<p>I wanted numbers instead of a vague "background tabs are throttled", so I measured it. Everything below was run on 27 October 2026 in Google Chrome 156.0.8078.47 on Linux. It was a real browser window with default settings, not headless. Each test page was hidden by opening a new tab in front of it. A small script read the page's variables once a minute over the DevTools protocol.</p>
<h2>The test page: five countdowns started at the same moment</h2>
<pre><code class="language-js">const DURATION = 25 * 60;
const start = Date.now();
const end = start + DURATION * 1000;

// 1. naive: subtract one second per tick
let naive = DURATION;
setInterval(() =&gt; { naive--; }, 1000);

// 2. deadline: recompute from the clock on every tick
let deadline = DURATION;
setInterval(() =&gt; {
  deadline = Math.max(0, Math.round((end - Date.now()) / 1000));
}, 1000);

// 3. chained setTimeout, naive
let chained = DURATION;
(function tick() {
  setTimeout(() =&gt; { chained--; tick(); }, 1000);
})();

// 4. a requestAnimationFrame counter: does it run at all while hidden?
let frames = 0;
(function frame() { frames++; requestAnimationFrame(frame); })();
</code></pre>
<p>A second version of the page also drove a naive countdown from a dedicated Web Worker. A third left out a line that wrote the time into <code>document.title</code> every second. That line turned out to matter a lot.</p>
<h2>Result 1: eight minutes in a background tab</h2>
<p>These are the readings from the page without a worker, hidden 6 seconds after it loaded:</p>
<pre><code class="language-text">hidden for   real time left   naive    chained setTimeout   deadline
1 min        23:53            23:54    23:57                23:54
3 min        21:53            21:54    22:04                21:54
8 min        16:53            17:23    17:47                16:54
</code></pre>
<p>After eight minutes the naive countdown was 30 seconds behind and the chained <code>setTimeout</code> was 54 seconds behind. The deadline version was right; its one-second difference comes from rounding and the moment of its last tick. When I brought the tab back to the front, the two tick-counting versions stayed wrong. Nothing in their code knows how much time has really passed.</p>
<p><code>requestAnimationFrame</code> stopped completely. The frame counter did not move once while the tab was hidden. Anything that updates a countdown from an animation loop simply freezes in the background.</p>
<p>On a different run, a page that also ran a worker was hidden for ten minutes. There the naive countdown ended 45 seconds behind and the chained one 80 seconds behind. The exact amount changes from run to run. The direction never does.</p>
<h2>Why the chained setTimeout drifts from the first minute</h2>
<p>The log of callback times explains the two kinds of drift. In the first five minutes the chained <code>setTimeout</code> had 17 gaps of 1,997 ms instead of 1,000 ms. The <code>setInterval</code> had none.</p>
<p>Chrome's article on <a href="https://developer.chrome.com/blog/timer-throttling-in-chrome-88">timer throttling in Chrome 88</a> describes the first stage. When a page is hidden, timers are put in a group that "the browser will check … once per second". Timers with similar timeouts get batched together. A <code>setInterval</code> stays in step with those checks. A chained <code>setTimeout</code> asks for 1,000 ms from the moment its callback ran, which is slightly after a check. Now and then it just misses one and waits for the next, so a second goes missing. Those missed seconds make up most of the 11-second lag at the three-minute reading in the table.</p>
<h2>Why both fall behind after five minutes</h2>
<p>The <code>setInterval</code> log shows a single 30,000 ms gap at the five-minute mark. That is the second stage, which the same Chrome article calls intensive throttling. It applies when all of these are true:</p>
<ul>
<li>the page has been hidden for more than 5 minutes,</li>
<li>the chain count is 5 or greater,</li>
<li>the page has been silent for at least 30 seconds,</li>
<li>WebRTC is not in use.</li>
</ul>
<p>Then "the browser will check timers in this group once per minute". The article also defines the chain: with <code>setInterval</code>, "each iteration is part of the chain". A once-a-second interval reaches a chain count of 5 after five seconds and stays there for good.</p>
<p>So the naive countdown lost 30 seconds in one go. But why did it then go back to ticking once per second, instead of losing most of every minute?</p>
<h2>Result 2: the line that wrote to document.title</h2>
<p>The answer is in Chromium's <a href="https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/scheduler/main_thread/page_scheduler_impl.cc">page scheduler</a>. When a page updates its title or favicon, intensive throttling is inhibited for same-origin frames for 3 seconds. The comment says this "enables alternating effects meant to grab the user's attention". My page updated the title every second, so each update kept the next one-second tick out of intensive throttling. Only the first tick after the five-minute mark had to wait for a once-a-minute check.</p>
<p>To test this, I ran the page with and without the title line side by side. I used the flag from the Chrome article, <code>--enable-features=IntensiveWakeUpThrottling:grace_period_seconds/10</code>, which starts intensive throttling 10 seconds after the page is hidden instead of 5 minutes. After about four minutes (247 s):</p>
<pre><code class="language-text">                                  interval ticks   naive shows   real time left
page that updates document.title  211              21:29         20:53
page that never touches it        62               23:58         20:53
</code></pre>
<p>Without the title update, the page got 62 interval callbacks in 247 seconds. The naive countdown was more than three minutes behind. That version's deadline countdown read the clock correctly every time it ran. But it ran only once a minute, so its stored value lagged by up to a minute. On the next tick after the tab came back it was correct again.</p>
<p>So whether a background countdown is 30 seconds off or three minutes off depends on whether the page happens to write its title. That makes a poor foundation for anything you rely on.</p>
<h2>Result 3: a long task does the same thing in a visible tab</h2>
<p>Throttling is not the only way to lose ticks. In a visible tab, I blocked the main thread for 5 seconds with a busy loop, the kind of long task that heavy rendering or a large <code>JSON.parse</code> can cause. The interval gaps were:</p>
<pre><code class="language-text">setInterval gaps (ms):     1000, 1000, 5224, 775
worker message gaps (ms):  1000, 1000, 5214, 0, 0, 0, 0, 786
</code></pre>
<p><code>setInterval</code> does not make up for callbacks it missed. It ran once after the block and carried on, and the naive countdown was 4 seconds behind (24:55 against a real 24:51). The worker kept ticking during the block. Its messages queued up and arrived together when the main thread was free, so the worker-driven count stayed correct.</p>
<p>In my hidden-tab runs the worker's one-second interval was not throttled either: one message a second for ten minutes, with the longest gap 1,008 ms. That is an observation from one browser, not a documented guarantee. A worker doesn't help if what you need is the screen to repaint or a sound to play.</p>
<h2>Result 4: the alarm at the end</h2>
<p>A countdown has two jobs: show the time left, and do something when it reaches zero. I tested the second job on its own. The tab was hidden, intensive throttling started after 10 seconds (the same flag), and I set a 90-second alarm three ways:</p>
<pre><code class="language-js">const due = Date.now() + 90000;

// A. one long setTimeout (chain count 1)
setTimeout(fire, 90000);

// B. a 1-second polling loop with chained setTimeout
(function poll() {
  setTimeout(() =&gt; Date.now() &gt;= due ? fire() : poll(), 1000);
})();

// C. setInterval polling every second
const id = setInterval(() =&gt; {
  if (Date.now() &gt;= due) { clearInterval(id); fire(); }
}, 1000);
</code></pre>
<pre><code class="language-text">A. single setTimeout    790 ms late
B. chained polling      38,791 ms late
C. setInterval polling  38,791 ms late
</code></pre>
<p>The single <code>setTimeout</code> has a chain count of 1. It never qualifies for intensive throttling, only the once-per-second check. The polling loops reach a chain count of 5 within seconds and get the once-a-minute check. Both fired at the same minute-aligned moment, almost 39 seconds after the deadline.</p>
<h2>A countdown that keeps time</h2>
<p>Put together, the fix is short:</p>
<pre><code class="language-js">function startCountdown(seconds, { render, done }) {
  const end = Date.now() + seconds * 1000;
  const left = () =&gt; Math.max(0, Math.ceil((end - Date.now()) / 1000));

  // Display: recompute from the clock, never count ticks
  const id = setInterval(() =&gt; render(left()), 1000);

  // Repaint at once when the user comes back
  const onVisible = () =&gt; {
    if (document.visibilityState === 'visible') render(left());
  };
  document.addEventListener('visibilitychange', onVisible);

  // The end: one timer, not a polling loop
  const alarm = setTimeout(() =&gt; {
    clearInterval(id);
    document.removeEventListener('visibilitychange', onVisible);
    render(0);
    done();
  }, seconds * 1000);

  render(left());
  return () =&gt; {
    clearInterval(id);
    clearTimeout(alarm);
    document.removeEventListener('visibilitychange', onVisible);
  };
}
</code></pre>
<p>What each part is for:</p>
<ul>
<li><strong>The display reads the clock.</strong> The interval is only a request to repaint. If ticks are dropped, batched or delayed, the next one still shows the right number. Pausing works the same way: save the time left when the user pauses, and set a new <code>end</code> when they resume.</li>
<li><strong><code>visibilitychange</code> repaints at once.</strong> With a throttled interval, a hidden tab can show a stale value for up to a minute. The event fixes the display as soon as the user looks.</li>
<li><strong>The end is a single <code>setTimeout</code>.</strong> In my test it fired within a second. A polling loop fired 39 seconds late.</li>
<li><strong>Don't count frames.</strong> <code>requestAnimationFrame</code> is fine for smooth animation while visible, but it stops while hidden.</li>
</ul>
<p><code>Date.now()</code> follows the system clock, so it jumps if the user or NTP changes the clock in the middle of a countdown. For a focus timer that is rarely a problem, and it is the clock the user compares against anyway.</p>
<h2>Other browsers</h2>
<p>Chrome is not the strictest. MDN's <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout">setTimeout page</a> says that "Firefox Desktop has a minimum timeout of 1 second for inactive tabs" and that "Firefox for Android has a minimum timeout of 15 minutes for inactive tabs and may unload them entirely." An unloaded tab runs no code at all. If a timer has to survive that, the end time needs to live somewhere that outlasts the page, such as <code>localStorage</code>, so a reloaded tab can work out where it should be. Anything that must alert the user at an exact moment on a phone is a job for a native notification, not a browser tab.</p>
<h2>Testing your own timer in two minutes</h2>
<p>You don't need to wait five minutes per experiment:</p>
<ol>
<li>Start Chrome with <code>--enable-features=IntensiveWakeUpThrottling:grace_period_seconds/10</code>.</li>
<li>Start your countdown next to a stopwatch that reads the clock. The <a href="https://findutils.com/productivity/stopwatch/">FindUtils Stopwatch</a> does. In my run its display only updated once a minute while hidden, but it showed the correct elapsed time as soon as the tab was back in front.</li>
<li>Switch to another tab for two or three minutes, then come back and compare the two.</li>
<li>Repeat with a page that doesn't change its title. That is the version that shows the real worst case.</li>
</ol>
<p>If you store end times as timestamps, the <a href="https://findutils.com/developers/unix-timestamp/">Unix Timestamp Converter</a> turns a <code>Date.now()</code> value like <code>1793079000000</code> into a readable date and back. The post on <a href="https://findutils.com/blog/unix-timestamp-seconds-vs-milliseconds/">seconds versus milliseconds</a> covers what goes wrong when a millisecond value like this one meets code that expects seconds.</p>
<h2>Summary</h2>
<ul>
<li><code>setInterval(fn, 1000)</code> means "about once a second while the page is visible and idle". Anything else is a bonus.</li>
<li>Hidden for eight minutes, counting ticks put a countdown 30 seconds behind. A chained <code>setTimeout</code> was 54 seconds behind. Without a title update, the gap after four minutes was more than three minutes.</li>
<li>Compute the time left from a deadline, repaint on <code>visibilitychange</code>, and use one <code>setTimeout</code> for the end.</li>
<li>Test with the grace-period flag, and test the version of your page that doesn't touch <code>document.title</code>.</li>
</ul>
<p>Need help with a project? The team behind FindUtils takes on freelance work. You can find us on <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">Upwork</a>. We also build <a href="https://www.upwork.com/services/product/development-it-a-react-native-app-for-ios-and-android-connected-to-your-web-app-2112442513572863000">React Native apps for iOS and Android connected to your web app</a>, where the same JavaScript timer code has to survive the app going to the background.</p>
]]></content:encoded></item><item><title><![CDATA[Reversing a String Is a Unicode Test: Indexing, Iteration and Palindromes in JavaScript, Python, PHP and Go]]></title><description><![CDATA[s[0] gives half an emoji, code-point reversal turns 🇸🇪 into 🇪🇸, and 🇸🇪🇪🇸 passes a palindrome check. Tested in JS, Python, PHP and Go.]]></description><link>https://findutils.hashnode.dev/reversing-a-string-is-a-unicode-test-indexing-iteration-and-palindromes-in-javascript-python-php-and-go</link><guid isPermaLink="true">https://findutils.hashnode.dev/reversing-a-string-is-a-unicode-test-indexing-iteration-and-palindromes-in-javascript-python-php-and-go</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Python]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Go Language]]></category><category><![CDATA[unicode]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Mon, 26 Oct 2026 06:41:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/b2c74ebb-af0c-4b35-8d63-626159238eab.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A reader replied to my post on <a href="https://medium.com/@findutils/cutting-text-to-a-limit-without-breaking-it-code-units-bytes-and-tokens-in-javascript-and-python-99f08d602107">cutting text to a limit</a> with a point worth its own article: the trouble starts before any cut, at array access and iteration. They linked <a href="https://wiesmann.codiferes.net/wordpress/archives/41500">a post on the palindrome problem</a>, and the palindrome is a good test. To check one, you index from both ends, step towards the middle and compare. Every step takes for granted that "one position" means "one character", and in Unicode text that is often wrong.</p>
<p>Every output below was produced on 26 October 2026 with Node 20.19.2 (ICU 76.1), Python 3.13.5 with the <code>regex</code> package (2026.9.29), PHP 8.4.27 with the intl and mbstring extensions, Go 1.27.3 with <a href="https://github.com/rivo/uniseg">rivo/uniseg</a> v0.4.7 and golang.org/x/text v0.43.0, and Chrome 156. Five test strings come up again and again:</p>
<pre><code class="language-text">"👍🏽 ok"     thumbs-up + skin-tone modifier, a space, two letters
"noël"       written as n o e + combining diaeresis (U+0308) + l
"🇸🇪"         the Swedish flag: regional indicators S and E
"👨‍👩‍👧"         man, woman, girl joined by two zero-width joiners
"été"        first é precomposed (U+00E9), last é = e + combining acute (U+0301)
</code></pre>
<p>The last one looks contrived, but it is ordinary. Text pasted from different sources, or typed on different keyboards, can mix the two forms of é in one string.</p>
<h2>1. What does s[0] give you?</h2>
<p>The same expression returns four different things, because each language indexes a different unit:</p>
<pre><code class="language-text">                 JavaScript s[0]   Python s[0]   PHP $s[0]   Go s[0]
"👍🏽 ok"          "\ud83d"          '👍'          byte f0     byte 0xf0
"🇸🇪"             "\ud83c"          '🇸' (S)      byte f0     byte 0xf0
"été"            "é"               'é'           byte c3     byte 0xc3
</code></pre>
<p>JavaScript indexes UTF-16 code units, so the first item of <code>"👍🏽 ok"</code> is half of a surrogate pair. Python indexes code points, so you get a whole thumbs-up without its skin tone. PHP and Go index bytes of UTF-8, so you get the first byte of a four-byte or two-byte sequence. Lengths disagree for the same reason:</p>
<pre><code class="language-text">"👍🏽 ok"   JS .length 7 | Python len 5 | PHP strlen 11, mb_strlen 5, grapheme_strlen 4 | Go len 11, RuneCount 5, uniseg 4
"🇸🇪"      JS .length 4 | Python len 2 | PHP strlen 8,  mb_strlen 2, grapheme_strlen 1 | Go len 8,  RuneCount 2, uniseg 1
"été"     JS .length 4 | Python len 4 | PHP strlen 6,  mb_strlen 4, grapheme_strlen 3 | Go len 6,  RuneCount 4, uniseg 3
</code></pre>
<p>A reader sees 4, 1 and 3 characters. Those are <strong>grapheme clusters</strong>, defined in <a href="https://www.unicode.org/reports/tr29/">Unicode UAX #29</a>. Only the grapheme count matches what's on screen, and it is the one no language gives you by default.</p>
<p>Go's <code>for range</code> loop is a useful reminder of what an index is. Over <code>"été"</code> (both é precomposed here) it yields <code>i=0 é</code>, <code>i=2 t</code>, <code>i=3 é</code>. The index is a byte offset, so it jumps from 0 to 2.</p>
<h2>2. Four ways to reverse a string, and which ones break</h2>
<p>Here are the usual one-liners, run on the test strings. "Code points" means <code>[...s].reverse()</code> in JavaScript, <code>s[::-1]</code> in Python, <code>mb_str_split</code> + <code>array_reverse</code> in PHP and a reversed <code>[]rune</code> in Go. All four gave identical results.</p>
<pre><code class="language-text">input       code units / bytes             code points       graphemes
"👍🏽 ok"     JS: "ko \udffd\ud83c\udc4d\ud83d"  "ko 🏽👍"           "ko 👍🏽"
"noël"      JS: "l\u0308eon"  (l̈eon)        "l̈eon"            "lëon"
"🇸🇪"        JS: "\uddea\ud83c\uddf8\ud83c"    "🇪🇸"  (Spain)     "🇸🇪"
"👨‍👩‍👧"        JS: lone surrogates             "👧‍👩‍👨"            "👨‍👩‍👧"
"été"       JS: "\u0301eté"                 "\u0301eté"       "été"
</code></pre>
<p>Reversing code units (<code>split('').reverse().join('')</code> in JavaScript) swaps the halves of each surrogate pair. Three of the five results fail <code>isWellFormed()</code>. Reversing bytes is worse: PHP's <code>strrev</code> and a reversed <code>[]byte</code> in Go produced invalid UTF-8 for <strong>all five</strong> inputs, including <code>"noël"</code> and <code>"été"</code>, which contain no emoji at all. The <a href="https://www.php.net/manual/en/function.strrev.php">PHP manual for strrev</a> describes it as reversing a string, and it does, byte by byte.</p>
<p>Reversing code points avoids broken encoding. The text is still wrong:</p>
<ul>
<li>The skin tone now comes before the thumbs-up, so it shows on its own.</li>
<li>The diaeresis moved from the e to the l ("l̈eon").</li>
<li>The family became girl, woman, man. That order isn't in Unicode's list of <a href="https://unicode.org/Public/emoji/latest/emoji-zwj-sequences.txt">recommended ZWJ sequences</a> (man, woman, girl is), so you can't count on fonts drawing it as one family.</li>
<li>In "été" the combining accent now starts the string, with nothing to sit on.</li>
<li>The Swedish flag became the Spanish flag. Regional indicators S + E make 🇸🇪 and E + S make 🇪🇸, so the reversal still looks valid. Nothing throws, and nothing looks broken.</li>
</ul>
<p>Only the grapheme column is what a reader would call "the same text backwards".</p>
<h2>3. Palindrome checks fail in both directions</h2>
<p>A palindrome check is <code>s === reverse(s)</code>, so it inherits every problem above. It can fail in two ways: it can say yes to a string that isn't a palindrome, or no to one that is.</p>
<pre><code class="language-text">                         code units   code points   graphemes   NFC + graphemes
"racecar"                true         true          true        true
"🇸🇪🇪🇸" (Sweden, Spain)   false        true          false       false
"👍🏽👍🏽"                   false        false         true        true
"été" (mixed forms)      false        false         false       true
"😀x😀"                   false        true          true        true
</code></pre>
<p>The flag pair is a <strong>false positive</strong>. Its code points are S, E, E, S, which is a palindrome, but on screen it is Sweden then Spain, and backwards it is Spain then Sweden. Python's <code>s == s[::-1]</code> returned <code>True</code> for it, and so would any check that works on code points.</p>
<p><code>"👍🏽👍🏽"</code> and <code>"😀x😀"</code> are <strong>false negatives</strong>. They read the same in both directions, but code units (and, for the skin tones, code points) don't.</p>
<p>The mixed "été" shows that graphemes alone aren't enough. The first é is one code point and the last is two, so the clusters differ until you normalize. After <code>normalize('NFC')</code>, both become U+00E9 and the check passes. Normalize before you compare, always.</p>
<h2>4. Sentences: punctuation, case and the Turkish İ</h2>
<p>Real palindrome checkers strip spaces and punctuation and ignore case. "A man, a plan, a canal: Panama" passed in every language once that was done. The Turkish classic "Ey Edip Adana'da pide ye" did too. In capitals it gets interesting:</p>
<pre><code class="language-text">"İ".toLowerCase()            -&gt; "i\u0307"   (i + combining dot above), length 2
"İ".toLocaleLowerCase("tr")  -&gt; "i"
Python "İ".lower()           -&gt; 'i\u0307'   ("İ".casefold() is the same)
PHP mb_strtolower("İ")       -&gt; 69 cc 87    (i + U+0307 in UTF-8)
Go strings.ToLower("İ")      -&gt; "i"
</code></pre>
<p>JavaScript, Python and PHP apply Unicode's full default mapping from <a href="https://unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt">SpecialCasing.txt</a>, which turns İ (dotted capital I) into a plain i followed by a combining dot above. Go's <code>strings.ToLower</code> uses the simple one-to-one mapping and returns "i". Only the Turkish and Azerbaijani rules map İ to i on purpose. So the same uppercase input gives different lowercase strings in different languages. Here is what that did to <code>"EY EDİP ADANA'DA PİDE YE"</code> in JavaScript:</p>
<pre><code class="language-text">drop spaces + punctuation:   "eyedi\u0307padanadapi\u0307deye"   code points: false   graphemes: true
keep only \p{L} and \p{N}:   "eyedipadanadapideye"               code points: true    graphemes: true
Turkish locale lowercase:    "eyedipadanadapideye"               code points: true
</code></pre>
<p>The first filter leaves the two combining dots in place. Reversing code points moves each dot onto the wrong letter, and a correct palindrome fails. The second filter happens to work, because <code>\p{L}</code> doesn't match the dot (it is a mark, <code>\p{M}</code>), but that is luck. A filter that keeps only letters also deletes every emoji, so <code>"🇸🇪🇪🇸"</code> turns into an empty string and passes as a palindrome. Grapheme comparison handled all of these correctly, with or without the Turkish locale. Use the locale when you know it, and compare graphemes either way.</p>
<p>The same default shows up in tools. The <a href="https://findutils.com/text/case-converter/">FindUtils Case Converter</a> lowercased "EDİP" to "edi̇p" (five code points) in Chrome. That is the Unicode default, and it's worth knowing if you paste Turkish text into any converter. The <a href="https://findutils.com/guides/case-converter-guide/">case converter guide</a> covers the other case styles.</p>
<h2>5. The two-pointer loop, fixed</h2>
<p>The classic version walks two indexes towards the middle. Over JavaScript code units, it returned <code>false</code> for <code>"👍🏽👍🏽"</code> and <code>"😀x😀"</code>. On <code>"😀x😀"</code> the first comparison is <code>\ud83d</code> against <code>\ude00</code>: the left pointer sees the first half of an emoji and the right pointer sees the second half. The loop itself is fine. The array it walks is the wrong one. Segment first, then walk:</p>
<pre><code class="language-js">export function isPalindrome(text, locale = 'en') {
  const s = text.toLocaleLowerCase(locale).normalize('NFC')
                .replace(/[\s\p{P}]/gu, '');   // drop spaces + punctuation, keep emoji and marks
  const g = Array.from(new Intl.Segmenter(locale, { granularity: 'grapheme' }).segment(s), x =&gt; x.segment);
  for (let i = 0, j = g.length - 1; i &lt; j; i++, j--) if (g[i] !== g[j]) return false;
  return true;
}
</code></pre>
<p>Results: the Panama sentence <code>true</code>; "EY EDİP ADANA'DA PİDE YE" <code>true</code> with both <code>'tr'</code> and <code>'en'</code>; mixed "été" <code>true</code>; <code>"👍🏽👍🏽"</code> and <code>"😀x😀"</code> <code>true</code>; <code>"🇸🇪🇪🇸"</code> <code>false</code>; <code>"👍🏽👍🏿"</code> (different skin tones) <code>false</code>.</p>
<p>The same idea in the other three languages:</p>
<pre><code class="language-python">import unicodedata, regex          # pip install regex; \X = one grapheme cluster
def is_palindrome(text):
    s = unicodedata.normalize('NFC', text.casefold())
    s = regex.sub(r'[\s\p{P}]', '', s)
    g = regex.findall(r'\X', s)
    return g == g[::-1]
</code></pre>
<pre><code class="language-php">function is_palindrome(string $text): bool {          // PHP 8.4+: grapheme_str_split
    $s = Normalizer::normalize(mb_strtolower($text, 'UTF-8'), Normalizer::FORM_C);
    $s = preg_replace('/[\s\p{P}]/u', '', $s);
    $g = grapheme_str_split($s);
    return $g === array_reverse($g);
}
</code></pre>
<p>In Go, the standard library stops at runes. Use <code>uniseg.NewGraphemes(s)</code> to get the clusters, <code>golang.org/x/text/unicode/norm</code> for NFC, and the same two-pointer loop over the cluster strings. The Python, PHP and Go versions returned the same results as JavaScript for every test string in this section, plus "Ey Edip Adana'da pide ye". <a href="https://www.php.net/manual/en/function.grapheme-str-split.php"><code>grapheme_str_split</code></a> is new in PHP 8.4. On older versions, <code>preg_split('/(\X)/u', ...)</code> does the same job.</p>
<h2>6. Get the expected output before you write the test</h2>
<p>To write unit tests for a reverse or palindrome function, you need expected values from a source you trust. I checked the browser's own segmenter through the <a href="https://findutils.com/text/text-reverse/">FindUtils Text Reverse</a> tool in Chrome 156, in Characters mode:</p>
<pre><code class="language-text">"👍🏽 ok"   -&gt; "ko 👍🏽"
"noël"    -&gt; "lëon"      (diaeresis still on the e)
"🇸🇪"      -&gt; "🇸🇪"
"👨‍👩‍👧"      -&gt; "👨‍👩‍👧"
"🇸🇪🇪🇸"    -&gt; "🇪🇸🇸🇪"     (Spain, Sweden: not equal to the input, so not a palindrome)
</code></pre>
<p>Every result matched the grapheme column above. Words and Lines modes are there too, which helps when a log or changelog comes out in the wrong order. To see how many units a string has before you index into it, the <a href="https://findutils.com/text/character-counter/">Character Counter</a> counts like JavaScript's <code>.length</code> (7 for <code>"👍🏽 ok"</code>, 4 for <code>"🇸🇪"</code> and for the mixed "été" when I tried it today), and the FindUtils post on <a href="https://findutils.com/blog/why-an-emoji-counts-as-2-characters-string-length-explained/">why an emoji counts as 2 characters</a> explains why the numbers differ.</p>
<h2>A checklist for any code that walks a string</h2>
<ol>
<li><strong>Know what your index counts:</strong> UTF-16 units (JavaScript, Java, C#), code points (Python), or bytes (PHP, Go, Rust, C).</li>
<li><strong>Never reverse bytes or code units</strong> of user text. PHP's <code>strrev</code> is for ASCII-only data.</li>
<li><strong>Segment into graphemes</strong> before reversing, comparing positions or taking "the first character": <code>Intl.Segmenter</code>, <code>regex</code> <code>\X</code>, <code>grapheme_str_split</code>, <code>uniseg</code>.</li>
<li><strong>Normalize to NFC</strong> before comparing, and lowercase with the user's locale when you know it.</li>
<li><strong>Filter out what you don't want</strong> (spaces, punctuation) instead of keeping only <code>\p{L}</code>, which silently drops emoji.</li>
<li><strong>Put the five test strings in your test suite.</strong> A flag pair, a skin tone, a ZWJ family, a combining mark and a mixed-form accent catch every bug in this post.</li>
</ol>
<hr />
<p><em>Need help with a project? The team behind FindUtils takes on freelance work on <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">Upwork</a>, including <a href="https://www.upwork.com/services/product/development-it-php-bug-fixes-a-php-8-upgrade-or-new-features-for-your-website-2112445447170920068">PHP bug fixes, a PHP 8 upgrade or new features for your website</a>, where <code>strlen</code> and <code>strrev</code> on UTF-8 text are a common find.</em></p>
]]></content:encoded></item><item><title><![CDATA[Cutting Text to a Limit Without Breaking It: Code Units, Bytes and Tokens in JavaScript and Python]]></title><description><![CDATA[Half an emoji from slice(), byte cuts that grow, token cuts that decode to a replacement character, and one grapheme-safe truncate in JS and Python.]]></description><link>https://findutils.hashnode.dev/cutting-text-to-a-limit-without-breaking-it-code-units-bytes-and-tokens-in-javascript-and-python</link><guid isPermaLink="true">https://findutils.hashnode.dev/cutting-text-to-a-limit-without-breaking-it-code-units-bytes-and-tokens-in-javascript-and-python</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Python]]></category><category><![CDATA[unicode]]></category><category><![CDATA[webdev]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Sun, 25 Oct 2026 06:29:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/5dbc4524-6793-4f47-baf1-ead55f5a97f1.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most apps cut text somewhere. A preview gets 160 characters, a push notification has a byte limit, a database column has a size, and a prompt for a language model has a token budget. The FindUtils post on <a href="https://findutils.com/blog/why-an-emoji-counts-as-2-characters-string-length-explained/">why an emoji counts as 2 characters</a> explains why one string has four different lengths. This post is about the next step: <strong>cutting</strong> the string to fit one of those lengths without leaving broken text behind.</p>
<p>Every output below was produced on 25 October 2026 with Node 20.19.2 (plus Node 22.23.4 and 24.22.1 for one timing test), Chrome 156 through Playwright, and Python 3.13.5 with the <code>regex</code> (2026.9.29) and <code>tiktoken</code> (0.14.0) packages. Four test strings come up again and again:</p>
<pre><code class="language-text">"Great job 👍🏽 team"     thumbs-up + skin-tone modifier
"Trip 🇹🇷🇩🇪 planned"     two flags, each made of two regional-indicator letters
"Family 👨‍👩‍👧 photo"     three people joined by two zero-width joiners
"Cafe\u0301 au lait"     e + combining accent (looks like "Café")
</code></pre>
<h2>1. A code-unit cut can leave half a character</h2>
<p>JavaScript's <code>slice</code> counts UTF-16 code units, the same unit as <code>.length</code> and the HTML <code>maxlength</code> attribute. Most emoji take two units (a surrogate pair), so a cut can land between them:</p>
<pre><code class="language-text">"Great job 👍🏽 team".slice(0, 11)   -&gt; "Great job \ud83d"   isWellFormed() false
"Great job 👍🏽 team".slice(0, 12)   -&gt; "Great job 👍"        skin tone lost
"Trip 🇹🇷🇩🇪 planned".slice(0, 7)    -&gt; "Trip 🇹"              half a flag
"Family 👨‍👩‍👧 photo".slice(0, 10)   -&gt; "Family 👨‍"            one person + a dangling joiner
"Cafe\u0301 au lait".slice(0, 4)     -&gt; "Cafe"                 accent gone
</code></pre>
<p>The first result is the dangerous one. <code>\ud83d</code> is a <em>lone surrogate</em>: half of a pair, which is not a valid character in any encoding. JavaScript doesn't complain. What happens next depends on where the string goes:</p>
<pre><code class="language-text">JSON.stringify          {"t":"Great job \ud83d"}        escaped, still "valid" JSON
TextEncoder / Buffer    ... 20 ef bf bd                 silently replaced with U+FFFD (�)
fetch() request body    ... 20 ef bf bd                 same: the server receives "�"
encodeURIComponent      URIError: URI malformed         throws
</code></pre>
<p>So in the browser the text quietly gains a <code>�</code>, unless it goes into a URL, where it throws. The JSON route is worse, because the problem travels. Python accepts that JSON and fails later, somewhere else:</p>
<pre><code class="language-text">json.loads('{"t": "Great job \\ud83d"}')   ok, a str of length 11
t.encode("utf-8")                         UnicodeEncodeError: surrogates not allowed
sqlite3 insert of t                       UnicodeEncodeError: 'utf-8' codec can't encode character '\ud83d' ...
json.dumps(t, ensure_ascii=False).encode  UnicodeEncodeError
</code></pre>
<p>The stack trace points at a database write in a worker, while the bug is a <code>slice</code> in a front end. Node 20 and current browsers have <code>String.prototype.isWellFormed()</code> and <code>toWellFormed()</code>, which are cheap guards at an API boundary. They don't fix the cut, though. They only turn half an emoji into <code>�</code>.</p>
<h2>2. Code points are not enough</h2>
<p>The usual next attempt is to cut by code points, with <code>[...text]</code> in JavaScript or plain slicing in Python, where strings are sequences of code points. That removes the lone surrogate, but not the problem:</p>
<pre><code class="language-text">[..."Great job 👍🏽 team"].slice(0, 11).join("")    -&gt; "Great job 👍"     (Python s[:11] gives the same)
[..."Family 👨‍👩‍👧 photo"].slice(0, 9).join("")    -&gt; "Family 👨‍"        ends with U+200D
[..."Trip 🇹🇷🇩🇪 planned"].slice(0, 6).join("")     -&gt; "Trip 🇹"
[..."Cafe\u0301 au lait"].slice(0, 4).join("")     -&gt; "Cafe"
</code></pre>
<p>Nothing throws, and every result is valid Unicode. Every result is also different text from what the user wrote. The skin tone is gone, a family turned into one man, a flag turned into the letter T, and "Café" turned into "Cafe". The unit a reader sees is the <strong>grapheme cluster</strong> from <a href="https://www.unicode.org/reports/tr29/">Unicode Standard Annex #29</a>, and that is the only safe place to cut.</p>
<h2>3. Cut on graphemes, measure in the budget's unit</h2>
<p>The fix keeps two things apart: <em>where</em> you may cut (between graphemes) and <em>how</em> you count (whatever unit the limit uses). In JavaScript, <code>Intl.Segmenter</code> gives graphemes:</p>
<pre><code class="language-js">const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });

export function truncate(text, max, { ending = '…', measure = s =&gt; s.length } = {}) {
  if (measure(text) &lt;= max) return text;           // fits: no ending
  const budget = max - measure(ending);            // the ending counts toward the limit
  let out = '';
  for (const { segment } of seg.segment(text)) {
    if (measure(out + segment) &gt; budget) break;    // stop before the grapheme that doesn't fit
    out += segment;
  }
  return out.trimEnd() + ending;
}
</code></pre>
<p>The results, with no ending and then with <code>…</code> (one code unit):</p>
<pre><code class="language-text">truncate("Great job 👍🏽 team", 12, {ending:""})  -&gt; "Great job"      | with …: "Great job…"
truncate("Trip 🇹🇷🇩🇪 planned", 9, {ending:""})   -&gt; "Trip 🇹🇷"        | with …: "Trip…"
truncate("Family 👨‍👩‍👧 photo", 12, {ending:""})  -&gt; "Family"         | with …: "Family…"
truncate("Cafe\u0301 au lait", 5, {ending:""})    -&gt; "Café"           | with …: "Caf…"
</code></pre>
<p>Look at the flag row. With a limit of 9 and no ending, "Trip 🇹🇷" fits exactly. With <code>…</code>, the budget drops to 8, so the Turkish flag no longer fits and the whole flag goes. That is correct: the result never goes over the limit and never contains a piece of a character. The result can be a few units shorter than the limit. A family emoji is 8 code units, so it either fits completely or it is left out.</p>
<p>Python's standard library has no grapheme segmentation, but the third-party <code>regex</code> module matches one grapheme with <code>\X</code>:</p>
<pre><code class="language-python">import regex

def truncate(text, max_units, ending='…', measure=len):
    if measure(text) &lt;= max_units:
        return text
    budget, out = max_units - measure(ending), ''
    for g in regex.findall(r'\X', text):
        if measure(out + g) &gt; budget:
            break
        out += g
    return out.rstrip() + ending
</code></pre>
<p><code>truncate("Trip 🇹🇷🇩🇪 planned", 7, '')</code> gives <code>"Trip 🇹🇷"</code>, and <code>truncate("Cafe\u0301 au lait", 4, '')</code> gives <code>"Caf"</code>. (Python's <code>len</code> counts code points. Pass a different <code>measure</code> if your limit is in UTF-16 units, bytes or tokens.)</p>
<p>I also ran the same strings through the FindUtils <a href="https://findutils.com/text/text-truncate/">Text Truncate</a> tool in Chrome, with Break on word off and no ending. It never split a character either: limit 11 or 12 on the thumbs-up string gave "Great job " (10), the family string gave "Family " (7) at limits 10 and 12, and "Cafe\u0301" at limit 4 gave "Caf". It keeps the trailing space, while the function above trims it. The <a href="https://findutils.com/guides/text-truncate-guide/">Text Truncate guide</a> covers the tool's settings. It is a quick way to get expected values for your own unit tests.</p>
<h2>4. Byte budgets: the naive cut can come out longer</h2>
<p>Byte limits are common: a column sized in bytes, a header, a push payload, a key-value store with a value size limit. The obvious approach is to encode, cut the bytes and decode:</p>
<pre><code class="language-text">text = "Family 👨‍👩‍👧 photo from Kraków"   (44 UTF-8 bytes)

Buffer.from(text).subarray(0, 10).toString()  -&gt; "Family �"            10 bytes
Buffer.from(text).subarray(0, 22).toString()  -&gt; "Family 👨‍👩‍�"         24 bytes (!)
truncate(text, 22, {ending: "", measure: utf8Bytes}) -&gt; "Family"     6 bytes
</code></pre>
<p>The second line is the surprise. The cut landed inside the third person (an emoji is 4 bytes in UTF-8), Node replaced the 2 leftover bytes with <code>�</code>, and <code>�</code> itself is 3 bytes. Re-encoded, the "22-byte" result is 24 bytes, which is over the limit you were trying to meet.</p>
<p>Python is stricter by default and fails loudly. Its lenient options still produce wrong text:</p>
<pre><code class="language-text">b[:10].decode("utf-8")              UnicodeDecodeError
b[:10].decode("utf-8", "ignore")    "Family "
b[:22].decode("utf-8", "ignore")    "Family 👨‍👩‍"        two people and a dangling joiner
b[:22].decode("utf-8", "replace")   "Family 👨‍👩‍�"
</code></pre>
<p><code>errors="ignore"</code> is a popular answer to "how do I cut UTF-8 safely", and it does give valid UTF-8. It doesn't give whole characters. The grapheme loop with a byte <code>measure</code> (<code>s =&gt; Buffer.byteLength(s, 'utf8')</code> in Node, <code>lambda s: len(s.encode('utf-8'))</code> in Python) returns "Family" here, because the family emoji alone is 18 bytes and doesn't fit in the remaining 15. A shorter result is the right answer. If you want to check a byte count by hand, the <a href="https://findutils.com/network/url-encoder-decoder/">URL Encoder</a> shows each UTF-8 byte as a <code>%XX</code> group.</p>
<h2>5. Token budgets: the same problem, one layer up</h2>
<p>Language-model budgets are in tokens, and tokenizers work on bytes. With OpenAI's <a href="https://github.com/openai/tiktoken">tiktoken</a> and the <code>o200k_base</code> encoding, the token pieces show where the boundaries fall:</p>
<pre><code class="language-text">"Great job 👍🏽 team"   6 tokens: 'Great' | ' job' | ' 👍' | f0 9f 8f | bd | ' team'
"Family 👨‍👩‍👧 photo"   10 tokens: the family takes 8 of them, each person split across two
</code></pre>
<p>The skin-tone modifier is split across two tokens, so keeping the first four tokens and decoding gives half a character:</p>
<pre><code class="language-text">enc.decode(ids[:4])  -&gt; "Great job 👍�"
enc.decode(ids[:2])  -&gt; "Family �"       (family string)
enc.decode(ids[:8])  -&gt; "Family 👨‍👩‍�"
</code></pre>
<p><code>decode</code> replaces the broken bytes by default, so this doesn't fail. It sends <code>�</code> to the model, or stores it in a summary. In a Turkish sample ("Merhaba, nasılsınız? Çok güzel!", 11 tokens) every token happened to end on a whole character. A test on a few English and Turkish strings will pass, and emoji are what break it.</p>
<p>The same grapheme loop works for tokens if <code>measure</code> counts tokens. There is one more catch, which I measured. <strong>The token count of a prefix isn't monotonic</strong>: across 202 grapheme prefixes of a mixed sample, a longer prefix had <em>fewer</em> tokens than the one before it 9 times, because the tokenizer merges pieces differently once more text follows. In practice, measure the final candidate together with its ending rather than adding up per-grapheme counts:</p>
<pre><code class="language-python">def truncate_tokens(text, max_tokens, ending='…'):
    if len(enc.encode(text)) &lt;= max_tokens:
        return text
    gs = regex.findall(r'\X', text)
    lo, hi = 0, len(gs)                      # binary search over grapheme prefixes
    while lo &lt; hi:
        mid = (lo + hi + 1) // 2
        if len(enc.encode(''.join(gs[:mid]).rstrip() + ending)) &lt;= max_tokens:
            lo = mid
        else:
            hi = mid - 1
    return ''.join(gs[:lo]).rstrip() + ending
</code></pre>
<p>Budgets of 6, 7 and 9 tokens on "Great job 👍🏽 team, see you tomorrow" gave "Great job 👍🏽…" (6), "Great job 👍🏽 team,…" (7) and "Great job 👍🏽 team, see…" (9). For budgets 6 to 9, a slow linear scan over every prefix picked the same cut each time. Because the count isn't strictly monotonic, a binary search can in principle stop one grapheme short of the best cut. It can never return something over the budget, since every candidate is measured with its ending.</p>
<h2>6. A performance trap in Node 20</h2>
<p><code>Intl.Segmenter</code> is the right tool, but on Node 20 it slows down badly on long strings. I timed iterating every grapheme of the same repeated sentence:</p>
<pre><code class="language-text">code units   Node 20.19.2   Node 22.23.4   Node 24.22.1   Chrome 156
10,000            20 ms         3.7 ms         1.0 ms        0.7 ms
40,000           195 ms         9.5 ms         2.4 ms        2.2 ms
80,000         8,660 ms        17.1 ms         8.8 ms        3.8 ms
</code></pre>
<p>The jump from 40,000 to 80,000 units on Node 20 was the same in two runs (8.9 s and 8.7 s). Iterating the same 80,000-unit string by code point took under 1 ms. The <code>truncate</code> above only walks as far as the limit, so cutting a 160,000-unit string to 280 took 50 ms even on Node 20. What hurts is code that segments the <em>whole</em> string first, for example to count graphemes or to cut from the end. Node 20 reached end-of-life on 30 April 2026 according to the <a href="https://github.com/nodejs/Release">Node.js release schedule</a>, so this is one more reason to move to 22 or 24.</p>
<h2>A checklist for any cut</h2>
<ol>
<li><strong>Name the unit of the limit</strong>: UTF-16 units (<code>maxlength</code>, most UIs), code points (Python, MySQL <code>CHAR_LENGTH</code>), bytes (storage, headers, payloads) or tokens (models).</li>
<li><strong>Cut between graphemes, always</strong>: <code>Intl.Segmenter</code> in JavaScript, <code>regex</code> <code>\X</code> in Python. Never call <code>slice</code>, <code>subarray</code> or <code>decode(errors="ignore")</code> on user text.</li>
<li><strong>Measure the candidate with its ending</strong>, and let the ending count toward the limit.</li>
<li><strong>Expect results shorter than the limit.</strong> A family emoji is 8 units, 18 bytes or several tokens, and it fits whole or not at all.</li>
<li><strong>Guard boundaries.</strong> Use <code>isWellFormed()</code> before sending JSON to a stricter service. Normalize to NFC first if two visually identical strings should get the same cut.</li>
<li><strong>Test with the four strings above</strong>: skin tone, flag, ZWJ family, combining accent. Plain English text will pass any implementation.</li>
<li><strong>Don't segment huge strings on Node 20.</strong> Stop at the limit, or upgrade.</li>
</ol>
<p>For quick checks while you write tests, the <a href="https://findutils.com/text/character-counter/">Character Counter</a> counts the way JavaScript does and the <a href="https://findutils.com/text/word-counter/">Word Counter</a> adds reading time for longer drafts.</p>
<hr />
<p><em>Need help with a project? The team behind FindUtils takes on freelance work on <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">Upwork</a>, including <a href="https://www.upwork.com/services/product/development-it-an-ai-feature-in-your-web-app-chat-assistant-document-analysis-or-search-2112348761417603851">adding an AI feature to your web app</a>, such as a chat assistant, document analysis or search.</em></p>
]]></content:encoded></item><item><title><![CDATA[Is It Prime? Trial Division, Miller-Rabin and the 2^53 Wall in JavaScript]]></title><description><![CDATA[A prime check is five lines until your numbers get big. Then every number looks even, modular exponentiation quietly returns garbage, and a famous shortcut…]]></description><link>https://findutils.hashnode.dev/is-it-prime-trial-division-miller-rabin-and-the-2-53-wall-in-javascript</link><guid isPermaLink="true">https://findutils.hashnode.dev/is-it-prime-trial-division-miller-rabin-and-the-2-53-wall-in-javascript</guid><category><![CDATA[Math]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Fri, 23 Oct 2026 06:50:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/6d3953c0-b545-43c5-87a6-e77fa51df55c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The FindUtils <a href="https://findutils.com/guides/prime-checker-guide/">Prime Checker guide</a> makes two choices I wanted to test in code. The tool uses plain trial division because it "never guesses", and it refuses any input above 9,007,199,254,740,991 instead of answering about a number you didn't type.</p>
<p>Both choices sound conservative. After running the alternatives, I think both are right, and the reasons are good to know for any JavaScript code that touches big integers: IDs, hashes, money in minor units, or a prime check of your own.</p>
<p>Everything below was run on Node 20.19.2. The Go and Python cross-checks used Go 1.27.3 and Python 3.13.5.</p>
<h2>Trial division, done properly</h2>
<pre><code class="language-js">function isPrimeTD(n) {
  if (!Number.isSafeInteger(n) || n &lt; 2) throw new RangeError(`not a safe integer &gt;= 2: ${n}`);
  if (n % 2 === 0) return n === 2;
  if (n % 3 === 0) return n === 3;
  for (let i = 5; i * i &lt;= n; i += 6) {        // 6k ± 1: skips multiples of 2 and 3
    if (n % i === 0 || n % (i + 2) === 0) return false;
  }
  return true;
}
</code></pre>
<p>Stopping at √n is safe: if n = a × b with a ≤ b, then a ≤ √n, so a composite number always has a factor no larger than its square root. The 6k ± 1 step skips two thirds of the candidates. Timings on a server CPU:</p>
<pre><code class="language-text">              561 composite      0.0 ms
       2147483647 prime          1.1 ms
    1000000000039 prime          2.7 ms
 9007199254740881 prime        281.5 ms
 9007199254740991 composite      0.0 ms
</code></pre>
<p>9,007,199,254,740,881 is the largest prime below 2^53, the guide's worst case, and it still finishes in under a third of a second. 2^53 − 1 itself falls instantly (it's divisible by 6,361). For anything a <code>Number</code> can hold, trial division is fast enough and exact.</p>
<p>The interesting line is the first one in the function.</p>
<h2>The wall: above 2^53, every number is even</h2>
<p>JavaScript numbers are 64-bit floats. They hold every integer exactly up to <code>Number.MAX_SAFE_INTEGER</code>, 2^53 − 1 (<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER">MDN</a>). Past that, the gaps between representable numbers are 2, then 4, then 8. Between 2^53 and 2^54, <strong>only even numbers exist</strong>:</p>
<pre><code class="language-text">Number('9007199254740993')                 9007199254740992
9007199254740993 === 9007199254740992   -&gt; true
Number('18446744073709551557') % 2      =  0
</code></pre>
<p>18,446,744,073,709,551,557 is the largest prime below 2^64. Parsed into a <code>Number</code>, it becomes an even number, and a naive check without the safety test answers confidently:</p>
<pre><code class="language-text">isPrimeNaive(Number('18446744073709551557')) -&gt; false   (it IS prime)
isPrimeTD                                    -&gt; not a safe integer &gt;= 2: 18446744073709552000
</code></pre>
<p>The wrong answer comes with no error and no warning. The guard throws instead, and the error message prints the number JavaScript actually received, which isn't the one in the input. That is the whole case for refusing large inputs: the code never saw your number.</p>
<h2>The second wall comes earlier: modular exponentiation</h2>
<p>Fast primality tests use modular exponentiation, <code>a^e mod m</code>, computed by repeated squaring. Each step multiplies two numbers smaller than m. If m has 13 digits, that product has up to 26, far past 2^53, and it gets rounded before the <code>%</code>:</p>
<pre><code class="language-js">function modpowNum(b, e, m) {           // looks fine, isn't
  let r = 1; b %= m;
  while (e &gt; 0) { if (e % 2 === 1) r = r * b % m; b = b * b % m; e = Math.floor(e / 2); }
  return r;
}
</code></pre>
<p>By Fermat's little theorem, 3^(p−1) mod p must be 1 for a prime p. With p = 1,000,000,000,039:</p>
<pre><code class="language-text">Number: 3^(m-1) mod m = 183510405447
BigInt: 3^(m-1) mod m = 1n
</code></pre>
<p>The modulus is a perfectly safe integer and the result is still garbage. <strong>The limit for multiply-then-mod with <code>Number</code> is about √(2^53) ≈ 94.9 million</strong>, not 2^53. Above that, use <code>BigInt</code>, which has no size limit:</p>
<pre><code class="language-js">function modpow(b, e, m) {
  let r = 1n; b %= m;
  while (e &gt; 0n) { if (e &amp; 1n) r = r * b % m; b = b * b % m; e &gt;&gt;= 1n; }
  return r;
}
</code></pre>
<h2>Fermat's shortcut and why 561 breaks it</h2>
<p>If n is prime, a^(n−1) mod n = 1 for every base a that shares no factor with n. The Fermat test checks a base or two and says "probably prime" when the result is 1. It's fast, and some composites pass it:</p>
<pre><code class="language-text">2^560 mod 561 = 1n            (561 = 3 x 11 x 17)
bases coprime to 561: 319, of which Fermat liars: 319
base-2 Fermat pseudoprimes below 2,000: 341, 561, 645, 1105, 1387, 1729, 1905
Carmichael numbers below 100,000: 561, 1105, 1729, 2465, 2821, 6601, 8911, 10585,
  15841, 29341, 41041, 46657, 52633, 62745, 63973, 75361
</code></pre>
<p>561 is a Carmichael number: <strong>every one</strong> of the 319 bases that share no factor with it says "prime". The guide explains why: 2, 10 and 16, one less than each prime factor, all divide 560. Picking more bases at random doesn't help unless you happen to pick a multiple of 3, 11 or 17.</p>
<h2>Miller-Rabin with BigInt</h2>
<p>Miller-Rabin fixes this. It writes n − 1 = d × 2^s, and instead of only checking that a^(n−1) ends at 1, it checks how the sequence a^d, a^(2d), … gets there. For a prime, that sequence either starts at 1 or passes through n − 1 (that is, −1) before it reaches 1. A composite that reaches 1 any other way has been caught, and Carmichael numbers get caught for most bases.</p>
<pre><code class="language-js">const BASES = [2n, 3n, 5n, 7n, 11n, 13n, 17n, 19n, 23n, 29n, 31n, 37n];
function isPrimeMR(n) {
  n = BigInt(n);
  if (n &lt; 2n) return false;
  for (const p of BASES) { if (n === p) return true; if (n % p === 0n) return false; }
  let d = n - 1n, s = 0;
  while ((d &amp; 1n) === 0n) { d &gt;&gt;= 1n; s++; }
  outer: for (const a of BASES) {
    let x = modpow(a, d, n);
    if (x === 1n || x === n - 1n) continue;
    for (let i = 1; i &lt; s; i++) { x = x * x % n; if (x === n - 1n) continue outer; }
    return false;                       // a proves n composite
  }
  return true;
}
</code></pre>
<p>Pass big values as strings (<code>isPrimeMR("18446744073709551557")</code>) so they never go through a <code>Number</code>. I compared it with trial division on every integer from 0 to 999,999 and on 2,000 pseudo-random numbers between 10^12 and 3.2 × 10^12. There were zero mismatches. Then the big ones:</p>
<pre><code class="language-text">            9007199254740881 prime        0.11 ms
            9007199254740993 composite    0.01 ms
        18446744073709551557 prime        0.27 ms
        18446744073709551559 composite    0.02 ms
 618970019642690137449562111 prime        0.43 ms
   3317044064679887385961981 prime        0.31 ms
</code></pre>
<p>The largest prime below 2^53 takes 0.11 ms instead of 281 ms. 2^89 − 1, a known Mersenne prime, takes under half a millisecond.</p>
<p>Now look at the last line. <strong>3,317,044,064,679,887,385,961,981 is not prime.</strong> A short Pollard rho script in Python factors it, and a second number like it, in about a second:</p>
<pre><code class="language-text">3317044064679887385961981 = 1287836182261 x 2575672364521
318665857834031151167461  = 399165290221 x 798330580441
</code></pre>
<p>Both are strong pseudoprimes to all twelve bases from 2 to 37. They are listed in <a href="https://oeis.org/A014233">OEIS A014233</a>, the smallest numbers that fool Miller-Rabin with the first k primes as bases. 318,665,857,834,031,151,167,461 is the smallest odd composite that fools all twelve. The larger one fools base 41 as well. That gives the rule:</p>
<ul>
<li>With the first 12 primes as bases, Miller-Rabin is <strong>exact for n &lt; 318,665,857,834,031,151,167,461</strong>, which covers every 64-bit integer (2^64 ≈ 1.8 × 10^19).</li>
<li>Above that bound, a fixed set of bases is a <strong>"probably prime"</strong>, not a proof. The 2^89 − 1 result above is right because that number really is prime, not because the test proved it.</li>
</ul>
<p>Go's standard library shows what a more robust test looks like. <code>big.Int.ProbablyPrime(0)</code> runs a Baillie-PSW test, and its <a href="https://pkg.go.dev/math/big#Int.ProbablyPrime">documentation</a> says it is "100% accurate for inputs less than 2⁶⁴". It rejected both pseudoprimes:</p>
<pre><code class="language-text">                         561  ProbablyPrime(0)=false
    318665857834031151167461  ProbablyPrime(0)=false
   3317044064679887385961981  ProbablyPrime(0)=false
 618970019642690137449562111  ProbablyPrime(0)=true
</code></pre>
<h2>Bonus: the same wall in LCM</h2>
<p>The <a href="https://findutils.com/blog/gcd-and-lcm-explained-euclids-algorithm-for-fractions-ratios-and-schedules/">GCD and LCM post</a> gives lcm(a, b) = a × b ÷ gcd(a, b). In floating point, the order of operations matters. The product overflows 2^53 long before the result does:</p>
<pre><code class="language-text">lcm(800900072081, 801100072099)  gcd=100000009
  a*b/gcd = 6416010477440890
  a/gcd*b = 6416010477440891
  BigInt  = 6416010477440891
</code></pre>
<p>The correct LCM is below 2^53, but <code>a*b</code> isn't, so multiplying first loses the last digit. Dividing by the GCD first keeps every intermediate value small. When the LCM itself is above 2^53, neither order helps: lcm(94906267, 94906265) prints 9007199326062756 either way, and only <code>BigInt</code> gives the true …755. Also watch for mixed types: <code>BigInt(a) / g(a, b)</code> throws <code>TypeError: Cannot mix BigInt and other types</code> if <code>g</code> returns a <code>Number</code>. That's annoying, but it's the language refusing to round your number silently, which is what you want.</p>
<h2>Rules I use now</h2>
<ol>
<li><strong>Validate first.</strong> <code>Number.isSafeInteger(n)</code> before any integer math. Above the limit, refuse, or take a string and switch to <code>BigInt</code>.</li>
<li><strong>Multiply-then-mod is safe only below about 94.9 million</strong> with <code>Number</code>. Any modular arithmetic on bigger numbers goes in <code>BigInt</code>.</li>
<li><strong>Trial division</strong> is exact and fast enough up to 2^53 (under 0.3 s in the worst case here). It's what a calculator should use there.</li>
<li><strong>Miller-Rabin with the first 12 primes</strong> is a proof for 64-bit inputs and fast far beyond, but above 3.2 × 10^23 it only says "probably". Use Baillie-PSW, or a certified test, when "probably" isn't enough.</li>
<li><strong>Never Fermat alone.</strong> 561 is the smallest of infinitely many Carmichael numbers.</li>
<li><strong>Divide before you multiply</strong> in LCM-style formulas, and use <code>BigInt</code> when the result may pass 2^53.</li>
</ol>
<p>If you just need an answer for one number, the <a href="https://findutils.com/calculate/prime-checker/">Prime Checker</a> runs trial division in your browser and shows the full factorization, like 360 = 2³ × 3² × 5. For anything above 2^53, you now know why it says no.</p>
<hr />
<p><em>Need help with a project? The team behind FindUtils takes on freelance work on <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">Upwork</a>, including <a href="https://www.upwork.com/services/product/development-it-a-fast-react-front-end-for-your-web-app-dashboard-or-admin-panel-2112446421681618043">fast React front ends for web apps and dashboards</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Version Strings Are Not Text: How npm, Python, Debian, PHP and Go Disagree About 1.0.0-1]]></title><description><![CDATA[Is 1.0.0-1 older or newer than 1.0.0? Three of six version parsers say newer, two say older, one refuses to parse it.]]></description><link>https://findutils.hashnode.dev/version-strings-are-not-text-how-npm-python-debian-php-and-go-disagree-about-1-0-0-1</link><guid isPermaLink="true">https://findutils.hashnode.dev/version-strings-are-not-text-how-npm-python-debian-php-and-go-disagree-about-1-0-0-1</guid><category><![CDATA[programming]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Python]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Go Language]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Thu, 22 Oct 2026 07:01:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/cc356c95-e593-40db-af1a-ace44ecc0bbf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>FindUtils' post <a href="https://findutils.com/blog/why-10-sorts-before-2-how-text-line-sorting-works/">Why 10 Sorts Before 2</a> shows why plain text sorting puts <code>v1.10</code> before <code>v1.9</code>, and its cheat sheet suggests "natural sort in code, or zero padding" for version numbers. That works until a release candidate shows up. My <a href="https://medium.com/@findutils/natural-sort-in-code-why-file10-lands-before-file2-and-the-fix-in-javascript-python-sql-go-and-b5d9c3c29c25">natural sort article</a> ended with that case: natural sort puts <code>2.0.0</code> <em>before</em> <code>2.0.0-rc.1</code>.</p>
<p>Versions need a version parser. The catch is that there are several, and they don't agree. Here is the string that shows it best:</p>
<pre><code class="language-text">1.0.0-1   vs   1.0.0
</code></pre>
<table>
<thead>
<tr>
<th>Parser</th>
<th>Verdict on <code>1.0.0-1</code></th>
</tr>
</thead>
<tbody><tr>
<td>npm <code>semver</code></td>
<td><strong>older</strong>: <code>-1</code> is a pre-release</td>
</tr>
<tr>
<td>Go <code>x/mod/semver</code> (<code>v1.0.0-1</code>)</td>
<td><strong>older</strong>: same rule</td>
</tr>
<tr>
<td>Python <code>packaging</code> (PEP 440)</td>
<td><strong>newer</strong>: <code>-1</code> means post-release <code>1.0.0.post1</code></td>
</tr>
<tr>
<td>Debian <code>dpkg</code></td>
<td><strong>newer</strong>: <code>-1</code> is the Debian revision</td>
</tr>
<tr>
<td>PHP <code>version_compare()</code></td>
<td><strong>newer</strong></td>
</tr>
<tr>
<td>Composer <code>VersionParser</code></td>
<td><strong>refuses</strong>: <code>Invalid version string "1.0.0-1"</code></td>
</tr>
</tbody></table>
<p>Every result in this article comes from a real run: Node 20.19 with <code>semver</code> 7.8.5, Python 3.13.5 with <code>packaging</code> 26.3, <code>dpkg</code> 1.22.22, GNU coreutils 9.7, PHP 8.4.26 with <code>composer/semver</code> 3.4.4, and Go 1.27.2 with <code>golang.org/x/mod</code> v0.42.0.</p>
<h2>The baseline: text sort and <code>sort -V</code></h2>
<pre><code class="language-text">== sort (LC_ALL=C)       == sort -V
1.0.0                    1.0.0
1.0.0-rc.1               1.0.0-rc.1
1.10.0                   1.2.0
1.2.0                    1.9.0
1.9.0                    1.10.0
</code></pre>
<p><code>sort -V</code> fixes the numbers but still puts the release before its release candidate. The right order is <code>1.0.0-rc.1</code>, then <code>1.0.0</code>.</p>
<h2>npm semver: pre-release identifiers have two rules</h2>
<pre><code class="language-js">const semver = require("semver");
semver.sort(["1.10.0", "1.9.0", "1.2.0", "1.0.0", "1.0.0-rc.1"]);
// [ '1.0.0-rc.1', '1.0.0', '1.2.0', '1.9.0', '1.10.0' ]
</code></pre>
<p>That's correct. Now the parts that surprise people:</p>
<pre><code class="language-text">alpha.2 vs alpha.10:   -1      (2 &lt; 10)
alpha2  vs alpha10:     1      (alpha2 &gt; alpha10)
+build.1 vs +build.2:   0      (equal)
valid('1.0'): null   valid('v1.0.0'): 1.0.0   coerce('1.0'): 1.0.0
</code></pre>
<p>The <a href="https://semver.org/">semver spec</a> compares dot-separated identifiers that are all digits as numbers, and everything else as ASCII text. <code>alpha.10</code> is two identifiers, so 10 beats 2. <code>alpha10</code> is one identifier, so it's compared character by character, and <code>"alpha2"</code> sorts after <code>"alpha10"</code>. Always put a dot before the number: <code>rc.1</code>, <code>beta.12</code>.</p>
<p>Build metadata after <code>+</code> is ignored for ordering, so <code>+build.1</code> and <code>+build.2</code> are equal. Don't put anything after <code>+</code> that has to sort.</p>
<p>Ranges have one more rule:</p>
<pre><code class="language-text">satisfies 1.3.0-beta.1 ^1.2.3:                      false
satisfies 1.3.0-beta.1 ^1.2.3 includePrerelease:    true
satisfies 1.2.3-beta.2 ^1.2.3-beta.1:               true
</code></pre>
<p><code>^1.2.3</code> won't match a pre-release of a <em>different</em> version, even though 1.3.0-beta.1 is numerically inside the range. A pre-release matches only when the range itself names a pre-release of the same <code>major.minor.patch</code>, or when you pass <code>includePrerelease</code>.</p>
<h2>Python packaging (PEP 440): normalization first</h2>
<pre><code class="language-python">from packaging.version import Version
sorted(["1.10.0", "1.9.0", "1.2.0", "1.0.0", "1.0.0rc1",
        "1.0.0.post1", "1.0.0.dev1", "1.0.0a1", "1!0.1"], key=Version)
</code></pre>
<pre><code class="language-text">['1.0.0.dev1', '1.0.0a1', '1.0.0rc1', '1.0.0', '1.0.0.post1', '1.2.0', '1.9.0', '1.10.0', '1!0.1']
</code></pre>
<p>PEP 440 has more phases than semver: dev releases come before alphas, post-releases after the final release, and an epoch (<code>1!</code>) beats everything without one. It is also forgiving about spelling, and that's where the <code>1.0.0-1</code> surprise comes from:</p>
<pre><code class="language-text">1.0 == 1.0.0: True
1.0.0-rc.1 -&gt; 1.0.0rc1     1.0.0RC1 -&gt; 1.0.0rc1     v1.0 -&gt; 1.0
1.0.0-1    -&gt; 1.0.0.post1  1.0.0_beta -&gt; 1.0.0b0
1.0.0-alpha.2 -&gt; 1.0.0a2   latest -&gt; InvalidVersion
</code></pre>
<p>Local versions (<code>1.0+ubuntu1</code>) sort after <code>1.0</code>, but <code>==1.0</code> still matches them.</p>
<p><strong>One change worth knowing if you upgraded recently.</strong> Specifier checks on pre-releases changed in <code>packaging</code> 26.0. Its changelog lists "PEP 440 handling of prereleases for <code>Specifier.contains</code>, <code>SpecifierSet.contains</code>, and <code>SpecifierSet.filter</code>". Same expression, three versions:</p>
<pre><code class="language-text">packaging 24.2   "2.0rc1" in SpecifierSet("&gt;=1.0")  -&gt;  False
packaging 25.0   "2.0rc1" in SpecifierSet("&gt;=1.0")  -&gt;  False
packaging 26.0   "2.0rc1" in SpecifierSet("&gt;=1.0")  -&gt;  True
</code></pre>
<p><code>filter()</code> in 26.3 still drops the release candidate when a final release also matches (<code>filter(["1.5", "2.0rc1"])</code> gives <code>['1.5']</code>), and keeps it when it's the only candidate. If your code uses <code>in</code> to decide whether to offer an upgrade, pin <code>packaging</code> or pass <code>prereleases=</code> explicitly. The <a href="https://packaging.pypa.io/en/stable/changelog.html">packaging changelog</a> has the details.</p>
<h2>Debian dpkg: <code>~</code> sorts before nothing, <code>-</code> splits the revision</h2>
<pre><code class="language-text">1.0~rc1   &lt; 1.0
1.0-rc1   &gt; 1.0
1.0~~     &lt; 1.0~
1.0       &lt; 1.0+b1
1:1.0     &gt; 2.0
1.0-1     &lt; 1.0-1ubuntu1
1.0.0     &gt; 1.0
2:1.0     &gt; 10.0
</code></pre>
<p>(<code>dpkg --compare-versions a lt b</code> for each pair.) The <a href="https://www.debian.org/doc/debian-policy/ch-controlfields.html#version">Debian policy</a> defines the format as <code>[epoch:]upstream_version[-debian_revision]</code>. A tilde sorts before everything, even the end of the string, so <code>1.0~rc1</code> is the Debian way to write a release candidate. A hyphen starts the revision, so <code>1.0-rc1</code> is "upstream 1.0, package revision rc1", which is <em>newer</em> than 1.0. That's the opposite of what an npm user would read.</p>
<p><code>sort -V</code> is close to these rules but not the same. It ignores epochs:</p>
<pre><code class="language-text">printf '10.0\n2:1.0\n' | sort -V    -&gt;   2:1.0, 10.0
dpkg says                           -&gt;   2:1.0 &gt; 10.0
</code></pre>
<h2>PHP: <code>version_compare()</code> and Composer don't agree with each other</h2>
<pre><code class="language-php">version_compare('1.0', '1.0.0');          // -1  (1.0 is older than 1.0.0)
version_compare('1.0.0-rc.1', '1.0.0');   // -1
version_compare('1.0.0-dev', '1.0.0-alpha'); // -1
version_compare('1.0.0-pl1', '1.0.0');    //  1  (patch level, newer)
version_compare('1.0.0-foo', '1.0.0-dev'); // -1 (unknown words sort first)
version_compare('1.0.0-1', '1.0.0');      //  1
version_compare(PHP_VERSION, '8.1.0', '&gt;='); // true on 8.4.26
</code></pre>
<p>The <a href="https://www.php.net/manual/en/function.version-compare.php"><code>version_compare</code> manual</a> documents that order of words: anything not in its list, then <code>dev</code>, <code>alpha</code>/<code>a</code>, <code>beta</code>/<code>b</code>, <code>RC</code>/<code>rc</code>, <code>#</code>, <code>pl</code>/<code>p</code>. The words are matched by their start, which leads to the nastiest result of the whole run:</p>
<pre><code class="language-text">version_compare('1.0.0-preview1', '1.0.0') =  1   (newer than the release!)
version_compare('1.0.0-pre1', '1.0.0')     =  1
version_compare('1.0.0-beat1', '1.0.0')    = -1   (typo of beta, still older)
version_compare('1.0.0-foo', '1.0.0')      = -1
</code></pre>
<p><code>preview</code> and <code>pre</code> begin with <code>p</code>, so they appear to be read as the patch-level marker and rank <em>after</em> 1.0.0. If you tag previews, use <code>alpha</code>, <code>beta</code> or <code>RC</code>. And <code>1.0</code> vs <code>1.0.0</code> is <em>not</em> a tie: the shorter one is older.</p>
<p>Composer's <code>composer/semver</code> (the library behind <code>composer.json</code> constraints) normalizes first:</p>
<pre><code class="language-text">normalize(1.0)          = 1.0.0.0
normalize(v1.0.0)       = 1.0.0.0
normalize(1.0.0-rc.1)   = 1.0.0.0-RC1
normalize(1.0.0-beta.2) = 1.0.0.0-beta2
normalize(1.0.0-1)      -&gt; UnexpectedValueException: Invalid version string "1.0.0-1"
Comparator::equalTo('1.0', '1.0.0')                         -&gt; false
Comparator::equalTo(normalize('1.0'), normalize('1.0.0'))   -&gt; true
</code></pre>
<p>Compare normalized strings, never raw ones. Constraints have their own stability rule:</p>
<pre><code class="language-text">parseConstraints('^2.0')                   -&gt; [&gt;= 2.0.0.0-dev &lt; 3.0.0.0-dev]
satisfies('2.0.0-RC1', '^1.2 || ^2.0')     -&gt; true
</code></pre>
<p>The constraint matches the RC. What keeps it out of your <code>vendor/</code> folder is <code>minimum-stability</code>, which, as the <a href="https://getcomposer.org/doc/04-schema.md#minimum-stability">Composer schema docs</a> put it, "defaults to <code>stable</code>" and ignores less stable versions "when resolving your project dependencies". So if you call <code>Semver::satisfies()</code> in your own code, for example to pick an upgrade or show "update available", check <code>VersionParser::parseStability()</code> as well. Here it returns <code>RC</code> for <code>2.0.0-RC1</code>.</p>
<p>This matters during PHP upgrades: <code>version_compare(PHP_VERSION, …)</code> guards are often written by hand, and <code>'8.1'</code> and <code>'8.1.0'</code> are not equal.</p>
<h2>Go: the <code>v</code> is required, and invalid compares equal</h2>
<pre><code class="language-go">tags := []string{"v1.10.0", "v1.9.0", "v1.2.0", "v1.0.0", "v1.0.0-rc.1"}
semver.Sort(tags)  // [v1.0.0-rc.1 v1.0.0 v1.2.0 v1.9.0 v1.10.0]
</code></pre>
<pre><code class="language-text">IsValid("1.10.0"): false       IsValid("v1.2"): true
Compare("1.10.0", "1.9.0"): 0
sorted without the v: [1.10.0 1.9.0 1.0.0]
Compare("v1.2", "v1.2.0"): 0
IsValid("v1.2+build.5"): false   Canonical("v1.2"): v1.2.0
Compare("1.10.0", "v0.0.1"): -1
</code></pre>
<p><a href="https://pkg.go.dev/golang.org/x/mod/semver"><code>golang.org/x/mod/semver</code></a> expects the <code>v</code> prefix that Go modules use. Without it every string is invalid, invalid strings compare equal to each other and below every valid one, and a sort quietly does nothing. The list came back in the order it went in, with no error. Check <code>IsValid</code> or add the <code>v</code> before sorting. Shorthand like <code>v1.2</code> is valid and equals <code>v1.2.0</code>, but not with a pre-release or build suffix.</p>
<h2>Cheat sheet</h2>
<table>
<thead>
<tr>
<th>Ecosystem</th>
<th>Parse and compare with</th>
<th>Watch out for</th>
</tr>
</thead>
<tbody><tr>
<td>npm / JS</td>
<td><code>semver.compare</code>, <code>semver.sort</code></td>
<td><code>alpha2</code> &gt; <code>alpha10</code> (use <code>alpha.10</code>); ranges skip other versions' pre-releases</td>
</tr>
<tr>
<td>Python</td>
<td><code>packaging.version.Version</code></td>
<td><code>1.0.0-1</code> is a post-release; <code>SpecifierSet</code> <code>in</code> changed in 26.0</td>
</tr>
<tr>
<td>Debian / Ubuntu</td>
<td><code>dpkg --compare-versions</code></td>
<td><code>~</code> for pre-releases; <code>-</code> starts the revision; <code>sort -V</code> ignores epochs</td>
</tr>
<tr>
<td>PHP</td>
<td><code>version_compare</code></td>
<td><code>1.0</code> &lt; <code>1.0.0</code>; <code>-preview1</code> ranks after the release</td>
</tr>
<tr>
<td>Composer</td>
<td><code>VersionParser::normalize</code>, <code>Comparator</code>, <code>Semver</code></td>
<td>Rejects <code>1.0.0-1</code>; constraints match RCs, <code>minimum-stability</code> filters them</td>
</tr>
<tr>
<td>Go</td>
<td><code>x/mod/semver</code></td>
<td>Needs the <code>v</code>; invalid strings compare equal, so sorts silently no-op</td>
</tr>
<tr>
<td>Any list of tags</td>
<td>The parser of the ecosystem that publishes them</td>
<td>Never text sort or natural sort</td>
</tr>
</tbody></table>
<h2>Rules I follow now</h2>
<ol>
<li>Parse versions with the library of the ecosystem the version comes from. A Debian package version in a Python script still goes through <code>dpkg --compare-versions</code>.</li>
<li>Write pre-releases so every parser agrees: <code>1.0.0-rc.1</code> in semver/npm/Go, <code>1.0.0rc1</code> in Python, <code>1.0.0~rc1</code> in Debian.</li>
<li>Never write <code>1.0.0-1</code> for anything. Three parsers call it newer, two older, one rejects it.</li>
<li>Validate before sorting. Go's sort and PHP's <code>version_compare</code> won't tell you when an input is nonsense, and in PHP a word like <code>preview</code> can even change the answer.</li>
<li>When you only need a sorted list of plain <code>x.y.z</code> releases in a browser tab, the <a href="https://findutils.com/text/text-line-sorter/">Text Line Sorter</a> won't do it either. The sorting post notes that Numeric mode reads <code>v1.10</code> as the decimal 1.1. Paste the list into one of the scripts above instead.</li>
</ol>
<hr />
<p><em>Need help with a project? The team behind FindUtils takes on freelance work on <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">Upwork</a>, including <a href="https://www.upwork.com/services/product/development-it-php-bug-fixes-a-php-8-upgrade-or-new-features-for-your-website-2112445447170920068">PHP bug fixes and PHP 8 upgrades</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Natural Sort in Code: Why file10 Lands Before file2, and the Fix in JavaScript, Python, SQL, Go and the Shell]]></title><description><![CDATA[FindUtils' post Why 10 Sorts Before 2 explains the rule behind the problem: text sorting compares one character at a time, so 1 beats 2 and file10.txt…]]></description><link>https://findutils.hashnode.dev/natural-sort-in-code-why-file10-lands-before-file2-and-the-fix-in-javascript-python-sql-go-and-the-shell</link><guid isPermaLink="true">https://findutils.hashnode.dev/natural-sort-in-code-why-file10-lands-before-file2-and-the-fix-in-javascript-python-sql-go-and-the-shell</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Python]]></category><category><![CDATA[SQL]]></category><category><![CDATA[Go Language]]></category><category><![CDATA[programming]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Wed, 21 Oct 2026 06:36:45 GMT</pubDate><content:encoded><![CDATA[<p>FindUtils' post <a href="https://findutils.com/blog/why-10-sorts-before-2-how-text-line-sorting-works/">Why 10 Sorts Before 2</a> explains the rule behind the problem: text sorting compares one character at a time, so <code>1</code> beats <code>2</code> and <code>file10.txt</code> comes before <code>file2.txt</code>. It also lists the three fixes: sort by number, use a natural sort, or pad the numbers with zeros.</p>
<p>This is the code-side companion. The same four file names go through JavaScript, Python, the shell, Postgres, SQLite, DuckDB and Go, followed by the cases where natural sort gives the wrong answer.</p>
<p>The list:</p>
<pre><code class="language-text">file10.txt  file2.txt  File3.txt  file1.txt
</code></pre>
<p>Everything below was run with Node 20.19, Python 3.13, GNU coreutils 9.7, PostgreSQL 17.11, SQLite 3.46, DuckDB 1.5.6 and Go 1.27 with <code>golang.org/x/text</code> v0.43. Outputs are copied from those runs.</p>
<h2>JavaScript: reuse one <code>Intl.Collator</code></h2>
<pre><code class="language-js">const files = ["file10.txt", "file2.txt", "File3.txt", "file1.txt"];

console.log([...files].sort());
const natural = new Intl.Collator("en", { numeric: true });
console.log([...files].sort(natural.compare));
</code></pre>
<pre><code class="language-text">[ 'File3.txt', 'file1.txt', 'file10.txt', 'file2.txt' ]
[ 'file1.txt', 'file2.txt', 'File3.txt', 'file10.txt' ]
</code></pre>
<p>The default <code>sort()</code> compares UTF-16 code units, so the capital <code>F</code> goes first and <code>10</code> beats <code>2</code>. The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator"><code>Intl.Collator</code></a> with <code>numeric: true</code> reads runs of digits as numbers and uses locale-aware letter order, so <code>File3.txt</code> sits between 2 and 10. Add <code>sensitivity: "base"</code> if <code>File3</code> and <code>file3</code> should count as equal.</p>
<p><code>a.localeCompare(b, "en", { numeric: true })</code> gives the same order, but it can be much slower. Sorting 100,000 file names:</p>
<pre><code class="language-text">localeCompare with options: 8056 ms
one Intl.Collator:          374 ms
</code></pre>
<p>Three runs gave 7.9 to 8.1 seconds against 0.29 to 0.37 seconds, more than 20 times slower. Build the collator once and pass <code>natural.compare</code>.</p>
<h2>Python: <code>re.split</code>, not <code>re.findall</code></h2>
<p>Python has no built-in natural sort, so the usual answer is a key function. The first version most people write breaks:</p>
<pre><code class="language-python">import re

def key_findall(s):
    return [int(t) if t.isdigit() else t for t in re.findall(r"\d+|\D+", s)]

sorted(["10 apples", "apple"], key=key_findall)
</code></pre>
<pre><code class="language-text">TypeError: '&lt;' not supported between instances of 'str' and 'int'
</code></pre>
<p><code>"10 apples"</code> starts with a number and <code>"apple"</code> with text, so Python ends up comparing <code>10</code> with <code>"apple"</code>. It passes on file names and fails on the first line that starts with a digit.</p>
<p><code>re.split</code> with a capture group always returns text, number, text, numberâ€¦, starting with text (an empty string if the line starts with a digit). Same-type values always meet:</p>
<pre><code class="language-python">def natural_key(s):
    return [int(t) if i % 2 else t.casefold() for i, t in enumerate(re.split(r"(\d+)", s))]

sorted(files, key=natural_key)
sorted(["10 apples", "apple", "9 pears"], key=natural_key)
</code></pre>
<pre><code class="language-text">['file1.txt', 'file2.txt', 'File3.txt', 'file10.txt']
['9 pears', '10 apples', 'apple']
</code></pre>
<p><code>casefold()</code> makes it case-insensitive. If you'd rather not maintain this, the <a href="https://pypi.org/project/natsort/">natsort</a> package's <code>natsorted()</code> gives the same order and handles more edge cases.</p>
<h2>The shell: <code>sort -V</code> and <code>ls -v</code></h2>
<pre><code class="language-text">$ printf '%s\n' file10.txt file2.txt File3.txt file1.txt | LC_ALL=C sort
File3.txt
file1.txt
file10.txt
file2.txt

$ printf '%s\n' file10.txt file2.txt File3.txt file1.txt | sort -V
File3.txt
file1.txt
file2.txt
file10.txt

$ printf '%s\n' file10.txt file2.txt File3.txt file1.txt | sort -n
File3.txt
file1.txt
file10.txt
file2.txt
</code></pre>
<p><code>sort -V</code> (version sort) fixes the numbers but stays case-sensitive, so <code>File3.txt</code> still goes first. <code>sort -n</code> doesn't help at all: it only reads a number at the start of a line. Here every line starts with a letter, so all of them tie and fall back to plain text order. For directory listings, <code>ls -v</code> ("natural sort of (version) numbers within text", says its help text) gave the same order here.</p>
<p>For dotted numbers without letters, field sorting is the most explicit option:</p>
<pre><code class="language-text">$ printf '%s\n' 1.10.0 1.9.2 2.0.0 1.10.1 | sort -t. -k1,1n -k2,2n -k3,3n
1.9.2
1.10.0
1.10.1
2.0.0
</code></pre>
<h2>Postgres: an ICU collation</h2>
<p>Postgres can do natural sort in the database with an ICU collation and the <code>kn</code> (numeric) option:</p>
<pre><code class="language-sql">CREATE COLLATION natural_sort (provider = icu, locale = 'und-u-kn-true');

SELECT string_agg(name, ', ' ORDER BY name COLLATE "C") AS bytewise FROM files;
SELECT string_agg(name, ', ' ORDER BY name COLLATE natural_sort) AS natural FROM files;
</code></pre>
<p>With a fifth row, <code>file02.txt</code>, added:</p>
<pre><code class="language-text">bytewise: File3.txt, file02.txt, file1.txt, file10.txt, file2.txt
natural:  file1.txt, file02.txt, file2.txt, File3.txt, file10.txt
</code></pre>
<p>(<code>natural</code> is a reserved word in both Postgres and SQLite, hence the name <code>natural_sort</code>.) You can also index with it, <code>CREATE INDEX ON files (name COLLATE natural_sort)</code>, so the database can use that index for an <code>ORDER BY</code> with the same collation. The <a href="https://www.postgresql.org/docs/current/collation.html">collation chapter</a> of the Postgres docs covers the ICU options.</p>
<p>Note where <code>file02.txt</code> landed: right next to <code>file2.txt</code>. To ICU's numeric comparison, <code>02</code> and <code>2</code> are the same number. A deterministic collation (the default) still breaks the tie by bytes. A <strong>non-deterministic</strong> one doesn't:</p>
<pre><code class="language-sql">CREATE COLLATION natural_ci (provider = icu, locale = 'und-u-kn-true-ks-level2', deterministic = false);
SELECT count(DISTINCT name COLLATE natural_ci) FROM files;     -- 4, not 5
SELECT name FROM files WHERE name = 'FILE2.TXT' COLLATE natural_ci;
</code></pre>
<pre><code class="language-text">distinct_ci: 4
name: file2.txt
name: file02.txt
</code></pre>
<p><code>FILE2.TXT</code> matches two different rows, and <code>DISTINCT</code> merges them. If you use a collation like this on a column with a <code>UNIQUE</code> constraint, <code>file02.txt</code> and <code>file2.txt</code> can't both exist. That may be what you want for user-facing names and a surprise for anything else. Use non-deterministic collations for comparisons only when you mean it, and keep keys bytewise.</p>
<h2>SQLite and DuckDB: no natural collation built in</h2>
<p>Neither has one, so there are two options. If every value has the same shape, sort on the number:</p>
<pre><code class="language-sql">-- SQLite: CAST reads the leading digits of '10.txt' as 10
SELECT name FROM files ORDER BY CAST(substr(name, 5) AS INTEGER);

-- DuckDB: text part, then the first number, then the name as a tie-breaker
SELECT name FROM files
ORDER BY lower(regexp_extract(name, '^\D*')),
         TRY_CAST(regexp_extract(name, '\d+') AS BIGINT),
         name;
</code></pre>
<p>Both return <code>file1.txt, file2.txt, File3.txt, file10.txt</code>. For any shape in SQLite, register a collation from your host language. In Python, <code>db.create_collation("natural_sort", fn)</code> takes a function that returns âˆ’1, 0 or 1 (a wrapper around <code>natural_key</code> works) and makes <code>ORDER BY name COLLATE natural_sort</code> work. The catch: it exists only on that connection. Another tool opening the same file doesn't have it.</p>
<h2>Go: <code>golang.org/x/text/collate</code></h2>
<p>The standard library's <code>slices.Sort</code> is bytewise. The <code>x/text</code> module has a numeric option:</p>
<pre><code class="language-go">c := collate.New(language.English, collate.Numeric)
c.SortStrings(files)
fmt.Println(c.CompareString("file02.txt", "file2.txt"))
</code></pre>
<pre><code class="language-text">slices.Sort [File3.txt file1.txt file10.txt file2.txt]
collate     [file1.txt file2.txt File3.txt file10.txt]
zeros       0
</code></pre>
<p>Same order as ICU and the same leading-zero equality. <code>CompareString</code> returns 0, so <code>file02.txt</code> and <code>file2.txt</code> are a tie. <a href="https://pkg.go.dev/golang.org/x/text/collate">collate on pkg.go.dev</a> lists the other options.</p>
<h2>Where natural sort gets it wrong</h2>
<p>Natural sort treats each run of digits as a whole number. That's right for file names and wrong for several other things. From the JavaScript collator:</p>
<pre><code class="language-text">zeros      0 0                                      // file02.txt vs file2.txt: equal
decimals   [ '$1.5', '$1.25', '$1.125' ]            // 5 &lt; 25 &lt; 125
negatives  [ '-5', '-10', '3' ]                     // the minus sign is just a character
semver     [ 'v1.9.2', 'v1.10.0', 'v2.0.0', 'v2.0.0-rc.1' ]
</code></pre>
<ul>
<li><strong>Leading zeros</strong> compare as equal. Your output order then depends on the input order, and anything that dedupes on "compare returns 0" will merge them. Python's <code>natural_key</code> gives the same: <code>natural_key("file02.txt") == natural_key("file2.txt")</code> is <code>True</code>.</li>
<li><strong>Decimals</strong> are split at the dot, so <code>1.5</code> is read as 1, then 5, and sorts below <code>1.25</code>. Sort prices and measurements as numbers, not as text.</li>
<li><strong>Negative numbers</strong> lose their sign, because <code>-</code> is just a character.</li>
<li><strong>Release candidates</strong> come after the release, because <code>v2.0.0</code> is a prefix of <code>v2.0.0-rc.1</code> and shorter sorts first. <code>sort -V</code> and <code>natsort</code> do the same. Semantic versioning says the opposite: a pre-release comes before its release. Use a version parser:</li>
</ul>
<pre><code class="language-python">from natsort import natsorted
from packaging.version import Version

tags = ["v2.0.0", "v2.0.0-rc.1", "v1.10.0", "v1.9.2"]
natsorted(tags)
sorted(tags, key=Version)
</code></pre>
<pre><code class="language-text">natsort   ['v1.9.2', 'v1.10.0', 'v2.0.0', 'v2.0.0-rc.1']
packaging ['v1.9.2', 'v1.10.0', 'v2.0.0-rc.1', 'v2.0.0']
</code></pre>
<p><code>packaging</code> follows Python's version rules (PEP 440), which accept this tag. In JavaScript, the <code>semver</code> package's <code>compare</code> does the same job for npm-style versions.</p>
<h2>When you don't need code</h2>
<p>For a one-off list in a browser tab, the FindUtils <a href="https://findutils.com/text/text-line-sorter/">Text Line Sorter</a> has no natural sort mode, but its Numeric mode reads the first number in each line, which covers <code>Item 2</code> / <code>Item 10</code> style lists. The sorting post notes its limits: only the first number counts and it is read as a decimal, so <code>1,500</code> reads as 1 and <code>v1.10</code> as 1.1.</p>
<p>If you name the files yourself, zero padding beats all of the above, because every program then sorts them the same way: <code>file001</code>, <code>file002</code>, <code>file010</code>. The <a href="https://findutils.com/text/line-prefix-suffix/">Line Prefix &amp; Suffix</a> tool can number a list with zero padding.</p>
<h2>Cheat sheet</h2>
<table>
<thead>
<tr>
<th>Where</th>
<th>Natural sort</th>
<th>Watch out for</th>
</tr>
</thead>
<tbody><tr>
<td>JavaScript</td>
<td><code>new Intl.Collator("en", { numeric: true }).compare</code></td>
<td>Build it once. <code>localeCompare</code> with options was over 20Ã— slower here</td>
</tr>
<tr>
<td>Python</td>
<td><code>key=natural_key</code> with <code>re.split(r"(\d+)", s)</code>, or <code>natsort</code></td>
<td><code>re.findall</code> keys raise <code>TypeError</code> on lines starting with a digit</td>
</tr>
<tr>
<td>Shell</td>
<td><code>sort -V</code>, <code>ls -v</code></td>
<td>Case-sensitive. <code>sort -n</code> only reads a leading number</td>
</tr>
<tr>
<td>Postgres</td>
<td>ICU collation <code>und-u-kn-true</code></td>
<td>Non-deterministic variants merge <code>file02</code> and <code>file2</code></td>
</tr>
<tr>
<td>SQLite / DuckDB</td>
<td><code>CAST</code>/<code>regexp_extract</code> on a known shape, or a host-language collation</td>
<td>A SQLite collation exists only on the connection that registered it</td>
</tr>
<tr>
<td>Go</td>
<td><code>collate.New(lang, collate.Numeric)</code></td>
<td>Leading zeros compare equal</td>
</tr>
<tr>
<td>Versions</td>
<td><code>packaging.Version</code>, npm <code>semver</code></td>
<td>Natural sort puts <code>-rc.1</code> after the release</td>
</tr>
<tr>
<td>Prices, negatives</td>
<td>Sort as numbers</td>
<td>Natural sort splits at <code>.</code> and ignores <code>-</code></td>
</tr>
</tbody></table>
<p>Pick the one that matches what the strings mean. File names want natural sort, versions want a version parser, and amounts want to be numbers before anything sorts them.</p>
<hr />
<p><em>Need a backend that gets details like this right? The team behind FindUtils takes on freelance work on <a href="https://www.upwork.com/freelancers/~010a5875eab37b4bd7">Upwork</a>, including <a href="https://www.upwork.com/services/product/development-it-a-fast-rest-api-or-back-end-service-built-in-go-2112349609254214411">fast REST APIs and back-end services built in Go</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Standard Deviation in Code: The Shortcut Formula That Returns 0, Welford's Fix, and Merging Daily Stats]]></title><description><![CDATA[FindUtils' explainer on sample vs population standard deviation covers the divisor question: n or n − 1, and which default Excel, NumPy, pandas and…]]></description><link>https://findutils.hashnode.dev/standard-deviation-in-code-the-shortcut-formula-that-returns-0-welford-s-fix-and-merging-daily-stats</link><guid isPermaLink="true">https://findutils.hashnode.dev/standard-deviation-in-code-the-shortcut-formula-that-returns-0-welford-s-fix-and-merging-daily-stats</guid><category><![CDATA[SQL]]></category><category><![CDATA[statistics]]></category><category><![CDATA[Data Science]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Tue, 20 Oct 2026 06:36:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/ded89230-d6b3-48dd-a50d-0f4afbdcf4ec.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>FindUtils' explainer on <a href="https://findutils.com/blog/sample-vs-population-standard-deviation-why-n-minus-1/">sample vs population standard deviation</a> covers the divisor question: n or n − 1, and which default Excel, NumPy, pandas and Python's <code>statistics</code> module use. This post covers what can still go wrong in your own code once the divisor is right:</p>
<ol>
<li>The one-pass "shortcut" formula returns 0, or NaN, on large values such as timestamps.</li>
<li>Averaging daily standard deviations, or storing the wrong totals in a summary table, gives a number that isn't the standard deviation of anything.</li>
<li>With n − 1, a group with one value becomes NaN, NULL or an exception, depending on the tool.</li>
</ol>
<p>Everything below was run with Node 20.19, Python 3.13, NumPy 2.5.4, pandas 3.0.6 and DuckDB 1.5.6. Outputs are copied from those runs.</p>
<p>A quick reference point first. For 2, 4, 4, 4, 5, 5, 7, 9 the population standard deviation is 2 and the sample one is about 2.138. The FindUtils <a href="https://findutils.com/calculate/statistics-calculator/">Statistics Calculator</a> shows 2.1380899353 in its default Sample (n-1) mode, which is handy for checking your own code against.</p>
<h2>Bug 1: the shortcut formula</h2>
<p>Most people who write standard deviation by hand reach for this identity, because it needs only one pass and two running totals:</p>
<p>Σ(x − mean)² = Σx² − (Σx)² / n</p>
<p>On paper it's exact. In floating point, it subtracts two huge, nearly equal numbers, and the small difference you want can disappear in rounding. Here are three implementations side by side:</p>
<pre><code class="language-js">// stddev.mjs: three ways to compute a sample standard deviation

// 1. The "shortcut" one-pass formula: sum and sum of squares.
export function stdevNaive(xs) {
  let n = 0, sum = 0, sumSq = 0;
  for (const x of xs) { n++; sum += x; sumSq += x * x; }
  const variance = (sumSq - (sum * sum) / n) / (n - 1);
  return Math.sqrt(variance);
}

// 2. Two passes: mean first, then squared distances from it.
export function stdevTwoPass(xs) {
  const n = xs.length;
  const mean = xs.reduce((a, b) =&gt; a + b, 0) / n;
  let ss = 0;
  for (const x of xs) ss += (x - mean) ** 2;
  return Math.sqrt(ss / (n - 1));
}

// 3. Welford: one pass, keeps count, mean and M2 (sum of squared distances).
export class RunningStats {
  n = 0; mean = 0; m2 = 0;
  push(x) {
    this.n++;
    const d = x - this.mean;
    this.mean += d / this.n;
    this.m2 += d * (x - this.mean);
  }
  variance({ ddof = 1 } = {}) {
    return this.n - ddof &gt; 0 ? this.m2 / (this.n - ddof) : NaN;
  }
  stdev(opts) { return Math.sqrt(this.variance(opts)); }
}
</code></pre>
<p>Now feed them something ordinary: eight request arrival times in Unix milliseconds, spread over about one second.</p>
<pre><code class="language-js">// stamps.mjs
import { stdevNaive, stdevTwoPass, RunningStats } from "./stddev.mjs";

const t0 = 1792479600000; // 2026-10-20T07:00:00Z
const arrivals = [0, 120, 135, 410, 455, 610, 870, 990].map((d) =&gt; t0 + d);
const w = new RunningStats();
arrivals.forEach((t) =&gt; w.push(t));
console.log("naive   :", stdevNaive(arrivals));
console.log("two-pass:", stdevTwoPass(arrivals));
console.log("welford :", w.stdev());
</code></pre>
<pre><code class="language-text">$ node stamps.mjs
naive   : 0
two-pass: 359.7295611189844
welford : 359.72959874234334
</code></pre>
<p>The shortcut says the arrivals have no spread at all. No error, no warning, just 0.</p>
<p><strong>Why it happens.</strong> Each timestamp is about 1.79 × 10¹², so each square is about 3.2 × 10²⁴, and the sum of eight squares is about 2.57 × 10²⁵. A double has 53 bits of precision, so at that size the gap between neighbouring representable numbers is 2³², about 4.3 billion. The quantity we actually want, Σ(x − mean)², is about 905,838. It's smaller than a single rounding step of the totals, so the subtraction can't recover it.</p>
<p>It doesn't take timestamps to hit this. I added a constant offset to 2, 4, 4, 4, 5, 5, 7, 9 (same spread, bigger numbers) and ran the shortcut:</p>
<table>
<thead>
<tr>
<th>Offset added</th>
<th>Shortcut result</th>
<th>Correct</th>
</tr>
</thead>
<tbody><tr>
<td>0 to 100,000,000</td>
<td>2.138089935299395</td>
<td>2.138089935299395</td>
</tr>
<tr>
<td>170,000,000</td>
<td>0</td>
<td>2.138089935299395</td>
</tr>
<tr>
<td>300,000,000</td>
<td>NaN</td>
<td>2.138089935299395</td>
</tr>
<tr>
<td>1,000,000,000</td>
<td>0</td>
<td>2.138089935299395</td>
</tr>
</tbody></table>
<p>NaN shows up when rounding makes the "variance" negative and <code>Math.sqrt</code> refuses it. So you get either a confident 0 or a NaN that spreads through every number computed from it.</p>
<p><strong>It's not a JavaScript problem.</strong> In Python, the same formula on floats also gives <code>0.0</code>. Python's integers are exact, but <code>s * s / n</code> divides too early and drops back to a float, which also gives <code>0.0</code>. Keeping the arithmetic in integers until the end works:</p>
<pre><code class="language-python">t0 = 1792479600000
xs = [t0 + d for d in [0, 120, 135, 410, 455, 610, 870, 990]]
n, s, sq = len(xs), sum(xs), sum(x * x for x in xs)
print(((n * sq - s * s) / (n * (n - 1))) ** 0.5)   # 359.7295611189844
</code></pre>
<p>SQL has the same trap, plus another one. In DuckDB, squaring a <code>BIGINT</code> timestamp fails outright:</p>
<pre><code class="language-text">Out of Range Error: Overflow in multiplication of INT64 (1792479600000 * 1792479600000)!
</code></pre>
<p>Cast to <code>DOUBLE</code> to get past the error, and the shortcut quietly returns <code>0.0</code> again.</p>
<p><strong>Library functions are fine.</strong> <code>statistics.stdev</code>, <code>np.std(..., ddof=1)</code>, pandas <code>.std()</code> and DuckDB's <code>stddev_samp</code> all returned 359.7… for the timestamps. The FindUtils Statistics Calculator gave 359.729561119 too. The bug lives in hand-rolled code: a quick script, a reduce in a dashboard, or a summary table built from <code>SUM(x)</code> and <code>SUM(x*x)</code>.</p>
<h3>Three fixes</h3>
<ul>
<li><strong>Two passes</strong> when you have all the data: compute the mean, then sum squared distances from it. It's exact to the last printed digit here.</li>
<li><strong>Welford's algorithm</strong> when you're streaming and can't keep the values. It updates the count, the mean and M2 one value at a time, and never forms the huge squares. Its result above, 359.72959874234334, differs from the exact value in the eighth significant digit. DuckDB's <code>stddev_samp</code> returned exactly the same 359.72959874234334.</li>
<li><strong>Subtract a reference value first.</strong> The spread doesn't change if you shift every value by the same constant. Subtract the first timestamp (or any fixed value close to the data), and even the shortcut works. In SQL:</li>
</ul>
<pre><code class="language-sql">SELECT sqrt((sum((ts_ms - 1792479600000)::DOUBLE ^ 2)
             - sum((ts_ms - 1792479600000)::DOUBLE)^2 / count(*)) / (count(*) - 1)) AS naive_shifted
FROM hits;
-- 359.7295611189844
</code></pre>
<p>Welford on the shifted values also gives 359.7295611189844. For timestamps, shifting first is a good habit anyway.</p>
<h2>Bug 2: averaging daily standard deviations</h2>
<p>Say you keep a summary per day instead of raw rows. The tempting move for a weekly number is to average the daily standard deviations. It's wrong, and not by a little.</p>
<p>The fix is to store three numbers per group (count, mean and M2) and combine them with the parallel formula from Chan, Golub and LeVeque, which merges two summaries exactly:</p>
<pre><code class="language-python">import statistics
from dataclasses import dataclass

@dataclass
class Summary:
    n: int = 0
    mean: float = 0.0
    m2: float = 0.0          # sum of squared distances from the mean

    def push(self, x):       # Welford's update
        self.n += 1
        d = x - self.mean
        self.mean += d / self.n
        self.m2 += d * (x - self.mean)

    def merge(self, other):  # combine two summaries (Chan et al.)
        n = self.n + other.n
        if n == 0:
            return Summary()
        d = other.mean - self.mean
        return Summary(n, self.mean + d * other.n / n,
                       self.m2 + other.m2 + d * d * self.n * other.n / n)

    def stdev(self, ddof=1):
        return (self.m2 / (self.n - ddof)) ** 0.5 if self.n &gt; ddof else float("nan")

# Response times in ms for two days (a quiet Monday, a slower Tuesday)
monday  = [112, 98, 130, 105, 121, 99, 117, 108]
tuesday = [180, 240, 210, 195, 260, 205, 230, 188, 199, 221, 215, 240]

days = {}
for name, xs in [("monday", monday), ("tuesday", tuesday)]:
    s = Summary()
    for x in xs:
        s.push(x)
    days[name] = s
    print(f"{name:8} n={s.n:2}  mean={s.mean:7.2f}  stdev={s.stdev():6.2f}")

week = days["monday"].merge(days["tuesday"])
print(f"merged   n={week.n}  mean={week.mean:7.2f}  stdev={week.stdev():6.2f}")
print(f"raw data n={len(monday + tuesday)}  mean={statistics.mean(monday + tuesday):7.2f}  stdev={statistics.stdev(monday + tuesday):6.2f}")
print(f"average of the two daily stdevs: {(days['monday'].stdev() + days['tuesday'].stdev()) / 2:6.2f}")
</code></pre>
<pre><code class="language-text">$ python3 merge.py
monday   n= 8  mean= 111.25  stdev= 11.06
tuesday  n=12  mean= 215.25  stdev= 23.88
merged   n=20  mean= 173.65  stdev= 55.74
raw data n=20  mean= 173.65  stdev= 55.74
average of the two daily stdevs:  17.47
</code></pre>
<p>The merged summary matches the raw data exactly. The average of the daily values, 17.47, is a third of the real answer. It only measures how much each day varies around its own mean, and misses that Tuesday sat about 100 ms above Monday. That shift between days is usually the thing you wanted to see.</p>
<p>The same works in SQL with any number of groups. Total M2 is the sum of the group M2 values plus each group's count times its squared distance from the overall mean:</p>
<pre><code class="language-sql">CREATE TABLE daily AS
SELECT day, count(*) AS n, avg(ms) AS mean, var_pop(ms) * count(*) AS m2
FROM requests GROUP BY day;

WITH g AS (SELECT sum(n * mean) / sum(n) AS mean FROM daily)
SELECT sum(n) AS n,
       round(g.mean, 2) AS mean,
       round(sqrt((sum(m2) + sum(n * (daily.mean - g.mean) ^ 2)) / (sum(n) - 1)), 2) AS stdev
FROM daily, g GROUP BY g.mean;
-- n = 20, mean = 173.65, stdev = 55.74 (stddev_samp over the raw rows: 55.74)
</code></pre>
<p>If your summary table already stores <code>count</code>, <code>sum</code> and <code>sum of squares</code>, it's fine for small values like these response times, but it brings back Bug 1 for anything large. Either store the mean and M2, or store the sums of shifted values.</p>
<p>To spot-check a merged number, paste the raw values into the Statistics Calculator. For these 20 response times it shows 55.7449785324.</p>
<h2>Bug 3: n − 1 when n = 1</h2>
<p>A sample standard deviation divides by n − 1, so a single value means dividing by zero. Every tool deals with that differently:</p>
<pre><code class="language-text">statistics.stdev([5]) : StatisticsError: stdev requires at least two data points
np.std([5], ddof=1)   : nan | warning: RuntimeWarning
pd.Series([5]).std()  : nan
</code></pre>
<p>DuckDB's <code>stddev_samp</code> over one row returned <code>NULL</code>, while <code>stddev_pop</code> returned <code>0.0</code>. In pandas, a <code>groupby</code> where one route has a single request quietly puts <code>NaN</code> in that row:</p>
<pre><code class="language-text">route
/a    163.273799
/b           NaN
</code></pre>
<p>As the FindUtils post notes, the Statistics Calculator shows 0 for a single value, which you should read as "not enough data". The same applies to your own dashboards. Don't <code>COALESCE</code> a NULL standard deviation to 0, because 0 means "no spread", which is a claim you have no data for. Show "n/a", or filter with <code>HAVING count(*) &gt;= 2</code> and display the count next to the number. A standard deviation of three values is a very rough estimate either way.</p>
<h2>Checklist</h2>
<ul>
<li>Don't hand-roll Σx² − (Σx)²/n. Use two passes, Welford or a library function.</li>
<li>Subtract a reference value before computing the spread of timestamps or other large numbers.</li>
<li>Store count, mean and M2 in summary tables, and merge with the parallel formula. Never average standard deviations.</li>
<li>Decide on purpose what one value means: NaN, NULL, an exception or "n/a". Never silently show 0.</li>
<li>Keep a known answer around for tests. 2, 4, 4, 4, 5, 5, 7, 9 should give 2 (population) and 2.1380899353 (sample).</li>
</ul>
<p>The divisor is the part everyone argues about. The arithmetic around it is what quietly turns a dashboard number into 0.</p>
]]></content:encoded></item><item><title><![CDATA[One Field, Five Spellings: Carrying user_profile_id From Postgres to a URL Without Breaking It]]></title><description><![CDATA[snake_case in the database, camelCase in TypeScript, CONSTANT_CASE in env vars, kebab-case in URLs. Where each convention comes from, where to convert, and…]]></description><link>https://findutils.hashnode.dev/one-field-five-spellings-carrying-user-profile-id-from-postgres-to-a-url-without-breaking-it</link><guid isPermaLink="true">https://findutils.hashnode.dev/one-field-five-spellings-carrying-user-profile-id-from-postgres-to-a-url-without-breaking-it</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[api]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[General Programming]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Mon, 19 Oct 2026 06:29:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/8af7d0b4-7859-4ad8-9fbd-53f48f80906c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Pick any field in a web app and follow it through the stack. It starts life as a database column, <code>user_profile_id</code>. The API sends it as a JSON key. The frontend reads it as <code>userProfileId</code>. Somewhere a config value is called <code>USER_PROFILE_ID</code>, a route reads <code>/user-profile-id/</code>, and a translation file has <code>user.profile.id</code>.</p>
<p>That's one idea with five spellings, and each one is right for the layer it lives in. Bugs show up at the borders: a key that didn't get renamed, a rename that lost a word, a type that describes a shape the code no longer has.</p>
<p>This post goes through each layer in turn: what its convention is and why, where I'd do the conversion, and a small TypeScript helper (tested with <code>tsc --strict</code>) that handles the edge cases most snippets skip.</p>
<h2>The map</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Spelling</th>
<th>Why that one</th>
</tr>
</thead>
<tbody><tr>
<td>SQL column</td>
<td><code>user_profile_id</code></td>
<td>Postgres folds unquoted names to lower case</td>
</tr>
<tr>
<td>JSON on the wire</td>
<td>pick one, document it</td>
<td>JSON doesn't care; your consumers do</td>
</tr>
<tr>
<td>TypeScript property</td>
<td><code>userProfileId</code></td>
<td>JavaScript style guides use lowerCamelCase</td>
</tr>
<tr>
<td>Environment variable</td>
<td><code>USER_PROFILE_ID</code></td>
<td>Shell and tooling convention</td>
</tr>
<tr>
<td>URL path / CSS class</td>
<td><code>user-profile-id</code></td>
<td>Hyphens read as word breaks</td>
</tr>
<tr>
<td>i18n / config key</td>
<td><code>user.profile.id</code></td>
<td>Dots express nesting</td>
</tr>
</tbody></table>
<h2>Layer 1: the database wants snake_case, and Postgres enforces it</h2>
<p>The <a href="https://www.postgresql.org/docs/current/sql-syntax-lexical.html">PostgreSQL docs</a> are explicit: "unquoted names are always folded to lower case". If you write <code>CREATE TABLE users (userProfileId int)</code>, the column is actually named <code>userprofileid</code>. The only way to keep the capital letters is to quote the name, <code>"userProfileId"</code>, and then every query has to quote it too, forever.</p>
<p>So the database gets snake_case, and the first conversion can already happen in SQL if you want it there. A quoted alias keeps its case:</p>
<pre><code class="language-sql">SELECT user_profile_id AS "userProfileId",
       created_at      AS "createdAt"
FROM users;
</code></pre>
<p>That works for one query. For a whole API, a single conversion step at the boundary is easier to keep consistent.</p>
<h2>Layer 2: convert once, at the API boundary</h2>
<p>JSON has no naming rules, so the choice is yours. What matters is that you make it once. Plenty of public APIs send snake_case and plenty send camelCase. The bug-prone setup is a mix: half the endpoints renamed in a serializer, half passing rows through untouched.</p>
<p>If your JSON is snake_case and your frontend is camelCase, convert in one function at the edge, and make the types follow it. Typing a raw response is quick with a generator. The <a href="https://findutils.com/guides/json-to-typescript-guide/">JSON to TypeScript guide</a> on FindUtils makes a point worth repeating here: generated types keep property keys exactly as they appear in the JSON, so <code>created_at</code> stays <code>created_at</code>. That's correct, because the types have to match what your code actually receives. But it also means that once you rename keys at runtime, the generated interface is describing the old shape.</p>
<p>TypeScript can do that rename at the type level, so you don't have to maintain two interfaces by hand:</p>
<pre><code class="language-ts">type CamelCase&lt;S extends string&gt; =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize&lt;CamelCase&lt;Tail&gt;&gt;}`
    : S;

type CamelKeys&lt;T&gt; =
  T extends readonly (infer U)[] ? CamelKeys&lt;U&gt;[]
  : T extends object
    ? { [K in keyof T as K extends string ? CamelCase&lt;K&gt; : K]: CamelKeys&lt;T[K]&gt; }
    : T;
</code></pre>
<p>And the runtime half, which walks objects and arrays:</p>
<pre><code class="language-ts">export function camelizeKeys&lt;T&gt;(value: T): CamelKeys&lt;T&gt; {
  if (Array.isArray(value)) return value.map(camelizeKeys) as CamelKeys&lt;T&gt;;
  if (value !== null &amp;&amp; typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([k, v]) =&gt; [toCamel(k), camelizeKeys(v)])
    ) as CamelKeys&lt;T&gt;;
  }
  return value as CamelKeys&lt;T&gt;;
}

interface UserRow {
  user_profile_id: number;
  created_at: string;
  last_login_ip: string | null;
  roles: { role_name: string }[];
}

const user = camelizeKeys(row as UserRow);
user.userProfileId;      // number
user.roles[0].roleName;  // string
user.user_profile_id;    // compile error: the old key is gone
</code></pre>
<p>Two warnings before you ship this, both of which I hit while testing:</p>
<ol>
<li><strong>Collisions.</strong> If a payload has both <code>user_id</code> and <code>userId</code> (it happens with legacy fields), both map to <code>userId</code> and the last one silently wins. If your data can contain both, check for duplicate output keys and throw.</li>
<li><strong>Type and runtime can disagree.</strong> For a key like <code>_id</code>, the template-literal type produces <code>Id</code> (empty head, capitalized tail), while the runtime splitter drops the leading underscore and produces <code>id</code>. The types say one thing and the object holds another. Leave keys like that alone, or special-case them in both places.</li>
</ol>
<h2>The hard part: finding the words</h2>
<p>Every conversion is really two steps: split the name into words, then join them in the new style. Joining is trivial. Splitting is where names get damaged.</p>
<p>Here's a splitter that handles separators, camel humps, digits and acronyms:</p>
<pre><code class="language-ts">export function words(name: string): string[] {
  return name
    .replace(/([a-z0-9])([A-Z])/g, "$1 $2")     // userProfile -&gt; user Profile
    .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")  // XMLHttp     -&gt; XML Http
    .split(/[^A-Za-z0-9]+/)                      // spaces, _, -, .
    .filter(Boolean)
    .map((w) =&gt; w.toLowerCase());
}

const cap = (w: string) =&gt; w.charAt(0).toUpperCase() + w.slice(1);
export const toCamel = (s: string) =&gt; words(s).map((w, i) =&gt; (i ? cap(w) : w)).join("");
export const toSnake = (s: string) =&gt; words(s).join("_");
export const toKebab = (s: string) =&gt; words(s).join("-");
export const toConstant = (s: string) =&gt; toSnake(s).toUpperCase();
</code></pre>
<p>The output, straight from running it:</p>
<table>
<thead>
<tr>
<th>Input</th>
<th>snake_case</th>
<th>camelCase</th>
<th>CONSTANT_CASE</th>
</tr>
</thead>
<tbody><tr>
<td><code>userProfileId</code></td>
<td><code>user_profile_id</code></td>
<td><code>userProfileId</code></td>
<td><code>USER_PROFILE_ID</code></td>
</tr>
<tr>
<td><code>XMLHttpRequest</code></td>
<td><code>xml_http_request</code></td>
<td><code>xmlHttpRequest</code></td>
<td><code>XML_HTTP_REQUEST</code></td>
</tr>
<tr>
<td><code>getHTTPResponse</code></td>
<td><code>get_http_response</code></td>
<td><code>getHttpResponse</code></td>
<td><code>GET_HTTP_RESPONSE</code></td>
</tr>
<tr>
<td><code>apiV2Key</code></td>
<td><code>api_v2_key</code></td>
<td><code>apiV2Key</code></td>
<td><code>API_V2_KEY</code></td>
</tr>
<tr>
<td><code>userID</code></td>
<td><code>user_id</code></td>
<td><code>userId</code></td>
<td><code>USER_ID</code></td>
</tr>
</tbody></table>
<p>Look at the last row. <code>userID</code> becomes <code>user_id</code>, and <code>user_id</code> comes back as <code>userId</code>, not <code>userID</code>. <strong>Case conversion isn't reversible</strong> once acronyms are involved. The practical rule: choose one canonical spelling (usually the database column) and derive the others from it. Never derive the canonical form back from a derived one.</p>
<p>For one-off renames (a ticket, a doc, a migration script), a browser tool is quicker than writing code. The FindUtils <a href="https://findutils.com/text/case-converter/">Case Converter</a> has ten cases, and its <a href="https://findutils.com/guides/case-converter-guide/">guide</a> is upfront about the limits. Its snake_case and kebab-case buttons split at lower-to-upper humps, but camelCase and PascalCase lowercase the input first and need visible separators. So <code>userProfileId</code> â†’ PascalCase gives <code>Userprofileid</code> unless you go through snake_case first. A run of capitals also stays one word, so <code>XMLHttpRequest</code> comes out as <code>xmlhttp_request</code>. That's the same trap the second regex above exists to avoid.</p>
<h2>Layer 3: environment variables, and the Turkish i</h2>
<p>Constants and environment variables are conventionally CONSTANT_CASE. POSIX is a little more nuanced than the folklore. It says the variables its standard utilities use consist "solely of uppercase letters, digits, and the ", and it reserves names containing lowercase letters for applications (<a href="https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html">POSIX.1-2017, chapter 8</a>). In practice almost every framework and hosting panel expects <code>MAX_RETRY_COUNT</code>, so that's what I use. Keep in mind that names are case-sensitive on Linux and case-insensitive on Windows, so never rely on case alone to tell two variables apart.</p>
<p>The trap here is locale. If you generate env names in JavaScript, use <code>toUpperCase()</code>, never <code>toLocaleUpperCase()</code>:</p>
<pre><code class="language-js">"user_id".toLocaleUpperCase("tr-TR"); // "USER_Ä°D"  (dotted capital I)
"user_id".toUpperCase();              // "USER_ID"
"TITLE".toLocaleLowerCase("tr-TR");   // "tÄ±tle"    (dotless i)
</code></pre>
<p>In Turkish and Azerbaijani, <code>i</code> uppercases to <code>Ä°</code> and <code>I</code> lowercases to <code>Ä±</code>. That's correct for prose and wrong for identifiers. A build script running on a machine with a Turkish locale will quietly produce <code>USER_Ä°D</code>, which matches nothing. Python's <code>str.upper()</code> doesn't depend on the locale, but code that calls ICU or locale-aware APIs can.</p>
<p>Going the other way, loading <code>MAX_RETRY_COUNT</code> into a <code>maxRetryCount</code> config property, is the same <code>toCamel</code> from above, applied to <code>Object.keys(process.env)</code> with a prefix filter.</p>
<h2>Layer 4: URLs, where encoding joins in</h2>
<p>For paths and slugs, use kebab-case. <a href="https://developers.google.com/search/docs/crawling-indexing/url-structure">Google's URL structure guidance</a> recommends hyphens over underscores so people and search engines can tell the words apart.</p>
<p>Separators are never the problem in URLs. RFC 3986 lists <code>-</code>, <code>.</code>, <code>_</code> and <code>~</code> as unreserved, so <code>encodeURIComponent("user-profile_id.v2~x")</code> returns the string unchanged. The trouble starts with <strong>values</strong>, not names:</p>
<pre><code class="language-js">encodeURIComponent("CafÃ© Menu");                       // "Caf%C3%A9%20Menu"
new URLSearchParams({ q: "CafÃ© &amp; Co+1" }).toString();  // "q=Caf%C3%A9+%26+Co%2B1"
</code></pre>
<p>Encoding makes any value safe to put in a URL, but it doesn't make it readable. For a path that people will see, transliterate instead. A <a href="https://findutils.com/seo/url-slug-generator/">URL slug generator</a> turns <code>CafÃ© Menu</code> into <code>cafe-menu</code>, and that's also the fix if you need an ASCII identifier from accented text, since the camelCase step above drops anything outside Aâ€“Z and 0â€“9. For a query value, encode it and don't transliterate. The FindUtils post on <a href="https://findutils.com/blog/url-encoding-explained-percent-encoding-and-query-strings/">percent-encoding and query strings</a> covers the <code>%20</code> vs <code>+</code> split and double encoding in detail.</p>
<p>Query parameter <strong>names</strong> deserve one rule: they're case-sensitive in <code>URLSearchParams</code> and in most server frameworks, so <code>?userId=7</code> and <code>?userid=7</code> are different parameters. Pick the same convention you use for JSON keys and stick to it.</p>
<h2>A checklist for your next field</h2>
<ul>
<li><strong>Choose one canonical spelling</strong> (normally the database column) and derive the rest from it.</li>
<li><strong>Don't fight Postgres.</strong> Use snake_case columns, or quote everywhere.</li>
<li><strong>Convert JSON keys once</strong>, in one function at the API edge, and derive the types (<code>CamelKeys&lt;T&gt;</code>) instead of keeping two interfaces.</li>
<li><strong>Guard against collisions</strong> (<code>user_id</code> + <code>userId</code>) and special keys (<code>_id</code>).</li>
<li><strong>Split acronyms deliberately</strong>, and remember that <code>userID</code> â†’ <code>user_id</code> â†’ <code>userId</code> doesn't round-trip.</li>
<li><strong>Use <code>toUpperCase()</code>, not <code>toLocaleUpperCase()</code></strong>, for identifiers.</li>
<li><strong>Slugs get transliterated, query values get encoded</strong>, and parameter names stay as consistent as your JSON keys.</li>
</ul>
<p>None of these conventions is better than the others. Each one is a local agreement. The code you write between layers is just translation, and translation goes wrong when it's done twice, done by hand, or done in a locale nobody expected.</p>
]]></content:encoded></item><item><title><![CDATA[Calendar Data Hygiene: Filter, Scrub and Expand Recurring Events in .ics Files]]></title><description><![CDATA[Two calendar chores keep coming up. Someone asks for your availability, so you export your calendar and send the .ics. Or finance wants every client…]]></description><link>https://findutils.hashnode.dev/calendar-data-hygiene-filter-scrub-and-expand-recurring-events-in-ics-files</link><guid isPermaLink="true">https://findutils.hashnode.dev/calendar-data-hygiene-filter-scrub-and-expand-recurring-events-in-ics-files</guid><category><![CDATA[privacy]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[calendar]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Sun, 18 Oct 2026 06:26:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/3675bd55-6747-4847-8ca6-6a3fd88fe825.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Two calendar chores keep coming up. Someone asks for your availability, so you export your calendar and send the <code>.ics</code>. Or finance wants every client meeting from last quarter in a spreadsheet, so you export the calendar and open it in a converter.</p>
<p>Both go wrong in ways that are easy to miss. The shared file carries every meeting title, Zoom password and attendee email address in it. The spreadsheet shows one row for a weekly standup that actually ran fifty times.</p>
<p>This post explains why, using a small sample file, and then walks through a three-step pipeline: <strong>filter</strong>, <strong>scrub</strong>, <strong>expand</strong>. I'll use free browser tools from <a href="https://findutils.com/">FindUtils</a> for each step. Their tool pages say the calendar is read in your browser and nothing is uploaded. The iCalendar details apply whatever tool you use.</p>
<h2>What's really inside an export</h2>
<p>Here's one recurring event plus one moved occurrence, in the format Google Calendar, Apple Calendar and Outlook all export (RFC 5545):</p>
<pre><code class="language-text">BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example//Calendar//EN
X-WR-CALNAME:deniz@example.com
BEGIN:VEVENT
UID:standup-42@example.com
DTSTAMP:20261001T080000Z
DTSTART;TZID=Europe/Berlin:20261005T093000
DTEND;TZID=Europe/Berlin:20261005T094500
RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=8
EXDATE;TZID=Europe/Berlin:20261014T093000
SUMMARY:Acme Corp standup (renewal talks)
DESCRIPTION:Join: https://zoom.us/j/81234567890?pwd=Qk9aU2xYdW1
 0ZzR4 Agenda: pricing for the Q1 renewal
LOCATION:Acme HQ\, Floor 3
ORGANIZER;CN=Deniz:mailto:deniz@example.com
ATTENDEE;CN=Sam Lee;PARTSTAT=ACCEPTED:mailto:sam@acme.example
CATEGORIES:Work
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Acme standup
TRIGGER:-PT10M
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:standup-42@example.com
RECURRENCE-ID;TZID=Europe/Berlin:20261021T093000
DTSTAMP:20261001T080000Z
DTSTART;TZID=Europe/Berlin:20261022T100000
DTEND;TZID=Europe/Berlin:20261022T101500
SUMMARY:Acme Corp standup (moved)
END:VEVENT
END:VCALENDAR
</code></pre>
<p>Three things stand out.</p>
<p><strong>1. Most of the lines say <em>what</em> the event is, not <em>when</em>.</strong> Only <code>DTSTART</code>, <code>DTEND</code>, <code>RRULE</code>, <code>EXDATE</code>, <code>RECURRENCE-ID</code> (plus <code>STATUS</code>/<code>TRANSP</code>) describe time. The rest â€” <code>SUMMARY</code>, <code>DESCRIPTION</code>, <code>LOCATION</code>, <code>ORGANIZER</code>, <code>ATTENDEE</code>, the alarm text, and even the calendar name (in a Google export it's often the owner's email address) â€” is the private part.</p>
<p><strong>2. Long lines are folded.</strong> RFC 5545 folds lines at 75 octets: a continuation line starts with a single space, and a reader joins it back without the space. The meeting password above is really <code>Qk9aU2xYdW10ZzR4</code>. A quick <code>grep</code> for it won't find it, and deleting the <code>DESCRIPTION</code> line by hand while leaving its continuation behind breaks the file. A fast way to see which properties a file carries:</p>
<pre><code class="language-bash"># count top-level property names (folded continuation lines start with a space, so they're skipped)
grep -oE '^[A-Z-]+' work.ics | sort | uniq -c | sort -rn
</code></pre>
<p>On a real export, the <code>ATTENDEE</code> and <code>DESCRIPTION</code> counts are usually the eye-opener. (If the password lives in a URL's query string, the <a href="https://findutils.com/blog/url-encoding-explained-percent-encoding-and-query-strings/">URL encoding explainer</a> covers how <code>?pwd=</code> and friends are put together.)</p>
<p><strong>3. Eight meetings are stored as one rule.</strong> The series is one <code>VEVENT</code> with <code>RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=8</code>. One date is removed by <code>EXDATE</code>, and the 21 October occurrence is replaced by a second <code>VEVENT</code> that shares the <code>UID</code> and names the occurrence it replaces in <code>RECURRENCE-ID</code>. A calendar app works all that out on display. A row-by-row converter doesn't.</p>
<p>If you want the full background on how these rules behave, the FindUtils post <a href="https://findutils.com/blog/rrule-explained-how-recurring-calendar-events-work/">RRULE Explained: How Recurring Calendar Events Really Work</a> goes through each part with real dates. The short version is below.</p>
<h2>What those eight "occurrences" actually are</h2>
<p>Worked out by hand (or with <code>python-dateutil</code>, see the end), the rule produces 5, 7, 12, 14, 19, 21, 26 and 28 October. Then:</p>
<ul>
<li><code>EXDATE</code> removes <strong>14 Oct</strong>. It does <em>not</em> add a ninth date at the end: <code>COUNT</code> counts the dates the rule generates, before exclusions. <code>DTSTART</code> counts as the first one.</li>
<li>The <code>RECURRENCE-ID</code> component moves <strong>21 Oct 09:30</strong> to <strong>22 Oct 10:00</strong>, with its own title.</li>
<li>So seven meetings happen: 5, 7, 12, 19, 22 (moved), 26, 28 October.</li>
</ul>
<p>There's also a time-zone trap. Berlin leaves summer time on 25 October 2026, so the meetings stay at 09:30 local time, but in UTC they move from 07:30Z to 08:30Z. RFC 5545 defines recurrence on the wall-clock time in the event's zone, which is what you want for a standup. A script that converts <code>DTSTART</code> to UTC first and then adds seven days will be an hour off after the change.</p>
<p>Two more rules that surprise people:</p>
<pre><code class="language-text">RRULE:FREQ=MONTHLY;BYMONTHDAY=31   -&gt; Aug 31, Oct 31, Dec 31, Jan 31, Mar 31 ...  (months without a 31st are skipped)
RRULE:FREQ=MONTHLY;BYMONTHDAY=-1   -&gt; Aug 31, Sep 30, Oct 31, Nov 30, Dec 31 ...  (last day of every month)
</code></pre>
<h2>Step 1: Filter, so you only process what you need</h2>
<p>A full export holds years of events, cancelled meetings and personal appointments next to work ones. Trim it first with <a href="https://findutils.com/convert/ics-filter/">ICS Filter</a>:</p>
<ul>
<li><strong>From / To</strong> dates, inclusive. An event is kept when any part of it touches those days.</li>
<li><strong>Keyword</strong>, matched case-insensitively in <code>SUMMARY</code>, <code>DESCRIPTION</code> and <code>LOCATION</code> (as one phrase, so "team lunch" won't match "lunch with the team").</li>
<li><strong>Category</strong> (comma-separated, any match) and <strong>Status</strong> (Confirmed, Tentative, Cancelled).</li>
<li><strong>Drop cancelled events</strong> is on by default.</li>
</ul>
<p>Filters combine with AND. Two details matter for recurring events:</p>
<ol>
<li>A series is <strong>kept or dropped as a whole</strong>. It stays if at least one occurrence (worked out from <code>RRULE</code>, <code>RDATE</code> and <code>EXDATE</code>) falls in the range, and the rule is copied unchanged.</li>
<li>You can download <strong>removed.ics</strong> as well, which is a record of exactly what the trim left out.</li>
</ol>
<p>Because UIDs are unchanged, importing the filtered file back into the calendar it came from updates the existing events instead of duplicating them.</p>
<h2>Step 2: Scrub before the file leaves your hands</h2>
<p>If the file is going to someone else â€” a recruiter, an assistant, a developer reproducing a bug â€” run it through <a href="https://findutils.com/convert/ics-privacy-scrub/">ICS Privacy Scrub</a>. Each field gets a rule: <strong>Keep</strong>, <strong>Generalize</strong> or <strong>Remove</strong>. The defaults, as the page documents them:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Default</th>
</tr>
</thead>
<tbody><tr>
<td>Title (<code>SUMMARY</code>)</td>
<td>Generalized to "Busy"</td>
</tr>
<tr>
<td>Description, location, links, attendees, organizer, attachments, comments</td>
<td>Removed</td>
</tr>
<tr>
<td>Alarms, vendor <code>X-</code> properties, calendar name</td>
<td>Removed</td>
</tr>
<tr>
<td>Categories</td>
<td>Kept</td>
</tr>
<tr>
<td>Times, <code>RRULE</code>, <code>EXDATE</code>, <code>RECURRENCE-ID</code>, <code>STATUS</code>, <code>TRANSP</code></td>
<td>Kept as they are</td>
</tr>
</tbody></table>
<p>Two switches are also on by default: <strong>New UIDs</strong> (so an imported copy doesn't overwrite the originals) and <strong>Mark private</strong> (<code>CLASS:PRIVATE</code> on every event). "Links" includes <code>URL</code>, <code>CONFERENCE</code> and the vendor meeting-link properties, so Meet and Teams links go too, not just Zoom.</p>
<p>A few things I like about how it's specified:</p>
<ul>
<li>With <strong>Generalize</strong>, attendees become numbered stand-ins (<code>Attendee 1</code>, the same address always gets the same number) and a link keeps only its scheme and host, such as <code>https://zoom.us/</code>.</li>
<li>With New UIDs on, a recurring series <strong>keeps one shared UID</strong>, so the moved 22 October occurrence still attaches to its series.</li>
<li><strong>Shift dates by</strong> N days makes a demo calendar from a real one. Use a multiple of 7 for weekday meetings, or a Monday standup will drift to another weekday (the page warns you).</li>
</ul>
<p>The page is honest about the limits: the result is <strong>not anonymous</strong>. With the defaults, the file still shows when you're busy, for how long, how often and in which category.</p>
<p>If all you want to share is availability, <a href="https://findutils.com/convert/ics-busy-free/">ICS Busy/Free</a> is the stricter option. It writes either Busy-only events that keep their repeat rule, or a single <code>VFREEBUSY</code> block of busy periods in UTC (as RFC 5545 requires), with no per-field choices.</p>
<h2>Step 3: Expand, when the destination can't read RRULE</h2>
<p>A spreadsheet, a CRM or billing import, or an older importer doesn't read the rule, so it shows one row, or keeps only the first occurrence. <a href="https://findutils.com/convert/ics-expand-rrule/">ICS Expand RRULE</a> turns each series into one event per occurrence inside a window, and writes <code>.ics</code>, JSON or CSV.</p>
<p>What it handles, per its documentation:</p>
<ul>
<li><code>FREQ=DAILY|WEEKLY|MONTHLY|YEARLY</code> with <code>INTERVAL</code>, <code>COUNT</code>, <code>UNTIL</code>, <code>BYDAY</code> (including ordinals like <code>2TU</code> and <code>-1FR</code>), <code>BYMONTHDAY</code> (negative counts from the month's end), <code>BYMONTH</code> and <code>WKST</code>.</li>
<li><code>EXDATE</code> removes, <code>RDATE</code> adds, and a <code>RECURRENCE-ID</code> component replaces the occurrence it names.</li>
<li>Times keep their <code>TZID</code> or trailing <code>Z</code>; the arithmetic runs on wall-clock time, so the Berlin example stays at 09:30 across the change.</li>
<li>A cap of 366 occurrences per series by default (max 2000) and 20,000 events in total. The notes say when a series was cut.</li>
</ul>
<p>And what it doesn't do: a rule using <code>BYSETPOS</code>, <code>BYYEARDAY</code>, <code>BYWEEKNO</code>, <code>BYHOUR</code>, <code>BYMINUTE</code>, <code>BYSECOND</code>, or an hourly-or-finer frequency keeps only its first occurrence, and the notes name the part that wasn't expanded. Check the notes before trusting a count.</p>
<p>Two practical tips:</p>
<ol>
<li><strong>Import the expanded <code>.ics</code> into a separate calendar.</strong> Each occurrence gets its own UID (series UID plus the occurrence start), so importing it next to the original series duplicates every meeting.</li>
<li><strong>CSV columns</strong> match ICS to CSV: <code>summary, start, end, all_day, timezone, location, description, organizer, status, categories, rrule, uid</code>, sorted by start. With "Skip cancelled series" on (the default), cancelled series and occurrences are left out and counted.</li>
</ol>
<p>For the sample file and a window of 1â€“31 October, you should get the seven meetings listed above, with the 22 October one at 10:00.</p>
<h2>Double-check the expansion in code</h2>
<p>If you'd like to verify a tricky rule independently, <code>python-dateutil</code> does the core arithmetic. Here's the sample series, with the exclusion and the move applied by hand:</p>
<pre><code class="language-python">from datetime import datetime
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr, rruleset

tz = ZoneInfo("Europe/Berlin")
start = datetime(2026, 10, 5, 9, 30, tzinfo=tz)

rs = rruleset()
rs.rrule(rrulestr("FREQ=WEEKLY;BYDAY=MO,WE;COUNT=8", dtstart=start))
rs.exdate(datetime(2026, 10, 14, 9, 30, tzinfo=tz))           # EXDATE

moved = {datetime(2026, 10, 21, 9, 30, tzinfo=tz):           # RECURRENCE-ID
         datetime(2026, 10, 22, 10, 0, tzinfo=tz)}           # new DTSTART

for occ in rs:
    occ = moved.get(occ, occ)
    print(occ.strftime("%a %d %b %H:%M %Z"), occ.astimezone(ZoneInfo("UTC")).strftime("%H:%MZ"))
</code></pre>
<p>Output:</p>
<pre><code class="language-text">Mon 05 Oct 09:30 CEST 07:30Z
Wed 07 Oct 09:30 CEST 07:30Z
Mon 12 Oct 09:30 CEST 07:30Z
Mon 19 Oct 09:30 CEST 07:30Z
Thu 22 Oct 10:00 CEST 08:00Z
Mon 26 Oct 09:30 CET 08:30Z
Wed 28 Oct 09:30 CET 08:30Z
</code></pre>
<p>That's a handy unit test for whatever does the expansion in your own pipeline. In practice the hard part is the plumbing around it: parsing folded lines, matching <code>RECURRENCE-ID</code> components to their series by UID, and handling <code>UNTIL</code>/<code>EXDATE</code> written in UTC for a zoned series.</p>
<h2>Bonus: combining and comparing calendars</h2>
<ul>
<li><a href="https://findutils.com/convert/merge-ics/">Merge ICS</a> folds up to twenty exports into one file (there's a <a href="https://findutils.com/guides/merge-ics/">guide</a>). Its optional dedupe key is <strong>UID + RECURRENCE-ID</strong>, so a moved occurrence is never mistaken for a duplicate of its series.</li>
<li><a href="https://findutils.com/convert/ics-diff/">ICS Diff</a> compares two exports by the same key and ignores <code>DTSTAMP</code>, <code>LAST-MODIFIED</code> and <code>SEQUENCE</code> by default, since apps rewrite them on every export.</li>
<li><a href="https://findutils.com/convert/ics-timezone-convert/">ICS Timezone Convert</a> renames Outlook's Windows zone names (such as <code>Eastern Standard Time</code>) to IANA names, a common reason imported events show up an hour off.</li>
</ul>
<h2>The checklist</h2>
<ol>
<li><strong>Filter</strong> to the date range, project or category you need. Keep <code>removed.ics</code>.</li>
<li><strong>Scrub</strong> anything that leaves your hands. Use Busy/Free if availability is all they need.</li>
<li><strong>Expand</strong> only for destinations that can't read <code>RRULE</code>, and import the result into a separate calendar.</li>
<li>Read the notes each tool prints: skipped months, capped series and unsupported rule parts are where silent errors hide.</li>
</ol>
<p>A calendar export is a record of who you met, where, and how to join the call. Treat it like any other data export: trim it, redact it, and know exactly what you're handing over.</p>
]]></content:encoded></item><item><title><![CDATA[From NDJSON Logs to a Spreadsheet: Flatten, Convert, Dedupe and Diff Without a Script]]></title><description><![CDATA[A repeatable browser workflow for JSON Lines job logs: get clean columns, keep the latest state per job, and see exactly what changed between two runs.]]></description><link>https://findutils.hashnode.dev/from-ndjson-logs-to-a-spreadsheet-flatten-convert-dedupe-and-diff-without-a-script</link><guid isPermaLink="true">https://findutils.hashnode.dev/from-ndjson-logs-to-a-spreadsheet-flatten-convert-dedupe-and-diff-without-a-script</guid><category><![CDATA[devtools]]></category><category><![CDATA[json]]></category><category><![CDATA[csv]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Sat, 17 Oct 2026 06:45:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/08d0daa5-e761-425c-ad98-77d996e3fb31.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Someone on your team asks a simple question: <em>"Which export jobs failed on Monday, and did the re-run fix them?"</em> The answer is in your logs. The logs are NDJSON (JSON Lines): one JSON object per line, with nested fields, a few retries written twice, and the odd line that got cut off halfway.</p>
<p>You could write a jq pipeline or a pandas script. Often, though, the person asking lives in a spreadsheet, and you need the answer in ten minutes. This post walks through a workflow I keep reusing. Every step runs in the browser with <a href="https://findutils.com/">FindUtils</a> tools, and each tool page says it processes the file in your tab and doesn't upload it:</p>
<ol>
<li><p><strong>NDJSON → CSV</strong> with nested fields flattened into columns</p>
</li>
<li><p><strong>Dedupe</strong> by <code>job_id</code>, keeping the <em>last</em> event, which gives the latest state per job</p>
</li>
<li><p><strong>Trim columns</strong> down to the ones that matter</p>
</li>
<li><p><strong>Diff</strong> two runs by key, so you see added, removed and changed jobs</p>
</li>
</ol>
<h2>The sample log</h2>
<p>Here is a small, made-up <code>run-1.ndjson</code> from a background export worker:</p>
<pre><code class="language-json">{"ts":"2026-10-12T09:00:01Z","job_id":"exp-101","status":"queued","attempt":1,"user":{"id":"u_17","plan":"pro"}}
{"ts":"2026-10-12T09:00:04Z","job_id":"exp-101","status":"failed","attempt":1,"user":{"id":"u_17","plan":"pro"},"error":{"code":"E_TIMEOUT"}}
{"ts":"2026-10-12T09:00:05Z","job_id":"exp-102","status":"done","attempt":1,"user":{"id":"u_42","plan":"free"},"tags":["csv","weekly"]}
{"ts":"2026-10-12T09:00:05Z","job_id":"exp-102","status":"done","attempt":1,"user":{"id":"u_42","plan":"free"},"tags":["csv","weekly"]}
{"ts":"2026-10-12T09:00:07Z","job_id":"exp-103","sta
{"ts":"2026-10-12T09:00:09Z","job_id":"exp-104","status":"failed","attempt":2,"user":{"id":"u_08","plan":"pro"},"error":{"code":"E_429"}}
</code></pre>
<p>It has all the usual problems: nested objects (<code>user</code>, <code>error</code>), an array (<code>tags</code>), a shipper retry that wrote line 4 twice, and a truncated line 5.</p>
<h3>Why the usual tools stumble</h3>
<ul>
<li><p><code>JSON.parse</code> <strong>on the whole file fails.</strong> An NDJSON file isn't one JSON document, so parsing everything at once breaks after the first line. Each line has to be parsed on its own.</p>
</li>
<li><p><strong>A "JSON to CSV" converter that expects an array</strong> can't read it either. And a converter that does read it often puts <code>user</code> into a single cell as <code>{"id":"u_17",...}</code>.</p>
</li>
<li><p><strong>jq works, but you have to know the keys up front</strong>, and it stops at the first broken line:</p>
</li>
</ul>
<pre><code class="language-bash">jq -r '[.ts, .job_id, .status, .attempt, .user.id, (.error.code // "")] | @csv' run-1.ndjson
# prints rows 1-4, then: jq: parse error: Invalid string ... at line 6
# (the cut-off string runs into the next line). exp-104 is never printed.
</code></pre>
<p>That's fine for a quick look. It's less fine when line 5 is line 48,213 and you need every row after it.</p>
<h2>Step 1: NDJSON → CSV, with nested fields as columns</h2>
<p>Open the <a href="https://findutils.com/convert/ndjson-to-csv/">NDJSON CSV Converter</a> and drop the file in. A few behaviours from its documentation matter here:</p>
<ul>
<li><p><strong>Every line is parsed separately.</strong> A line that isn't valid JSON is skipped, counted, and listed in the notes as <code>Line N: invalid JSON</code>. The other lines still convert, so you lose one row instead of the whole job, and you know exactly which line to chase.</p>
</li>
<li><p><strong>The header is the union of every key in every row</strong>, in the order keys first appear. A key that only shows up on line 900 still gets a column.</p>
</li>
<li><p><strong>Flatten</strong> (the default) turns nested objects into dot paths (<code>user.id</code>, <code>error.code</code>) and arrays into indexed columns (<code>tags[0]</code>, <code>tags[1]</code>). Choose <strong>JSON cell</strong> if you'd rather keep an object or array as JSON text in one cell.</p>
</li>
<li><p><code>null</code> and a missing key both become an empty cell. Numbers and booleans are written as text, and your spreadsheet decides the types when it opens the file.</p>
</li>
<li><p>There are <strong>Separator</strong> (comma, semicolon, tab) and <strong>Excel BOM</strong> options for Excel setups that expect semicolons or show accented characters wrongly.</p>
</li>
</ul>
<p>For the sample, you'd get:</p>
<pre><code class="language-csv">ts,job_id,status,attempt,user.id,user.plan,error.code,tags[0],tags[1]
2026-10-12T09:00:01Z,exp-101,queued,1,u_17,pro,,,
2026-10-12T09:00:04Z,exp-101,failed,1,u_17,pro,E_TIMEOUT,,
2026-10-12T09:00:05Z,exp-102,done,1,u_42,free,,csv,weekly
2026-10-12T09:00:05Z,exp-102,done,1,u_42,free,,csv,weekly
2026-10-12T09:00:09Z,exp-104,failed,2,u_08,pro,E_429,,
</code></pre>
<p>The notes also tell you line 5 was skipped, so <code>exp-103</code> is missing from this run. That's worth knowing before you report "3 jobs".</p>
<p><strong>Big files:</strong> the converter's input cap is 10 MB. For a larger log, slice it first with standard tools and convert the slice:</p>
<pre><code class="language-bash"># only the lines for one worker, or split into 50k-line parts
grep '"worker":"export"' app.ndjson &gt; export-only.ndjson
split -l 50000 app.ndjson part-
</code></pre>
<h3>Side note: when the source is a JSON array instead</h3>
<p>If you're starting from an API response such as <code>{"data":[...]}</code> rather than a log file, use <a href="https://findutils.com/convert/json-flatten/">JSON Flatten</a>. It finds the array under a <code>data</code>, <code>results</code>, <code>items</code> or <code>records</code> wrapper (or one level below, like <code>data.items</code>), and you can type any other location in <strong>Array path</strong>. Arrays can be joined with <code>|</code>, kept as JSON text, or spread into indexed columns, and a <strong>Depth</strong> setting limits how deep the dot paths go. One caveat from its tips applies to any JavaScript JSON parser: whole numbers above 9,007,199,254,740,991 lose precision, so have very long IDs sent as strings.</p>
<h2>Step 2: keep the latest state per job (dedupe, keep last)</h2>
<p>A log is a history. For "what state is each job in?", you want one row per <code>job_id</code>, specifically the <strong>last</strong> one.</p>
<p>Load the CSV into <a href="https://findutils.com/convert/csv-dedupe/">CSV Dedupe</a>:</p>
<ol>
<li><p>Pick <code>job_id</code> as the key column. With no key picked, only rows that match in every cell count as duplicates, so you'd catch the retry on line 4 and nothing else.</p>
</li>
<li><p>Under "When a row repeats, keep", choose <strong>Last row</strong>.</p>
</li>
<li><p>Leave <strong>Trim whitespace</strong> on (the default). Turn on <strong>Ignore case</strong> only if your IDs can differ in case.</p>
</li>
</ol>
<p>The result:</p>
<pre><code class="language-csv">ts,job_id,status,attempt,user.id,user.plan,error.code,tags[0],tags[1]
2026-10-12T09:00:04Z,exp-101,failed,1,u_17,pro,E_TIMEOUT,,
2026-10-12T09:00:05Z,exp-102,done,1,u_42,free,,csv,weekly
2026-10-12T09:00:09Z,exp-104,failed,2,u_08,pro,E_429,,
</code></pre>
<p>Two things make this safe to hand to someone else:</p>
<ul>
<li><p>You can download <code>duplicates.csv</code> as well as <code>cleaned.csv</code>. That's a record of every row that was removed, so you can check each decision.</p>
</li>
<li><p>Kept rows stay in their original file order. "Keep last" means last <em>in the file</em>, so make sure the log is in time order (most shippers append in order, but merged files may not be).</p>
</li>
</ul>
<p>Also note how blank keys behave: by default a row with an empty key is kept as unique. Ten events with no <code>job_id</code> won't collapse into one unless you choose <strong>Collapse into one</strong>.</p>
<h2>Step 3: trim to the columns you want to compare</h2>
<p>Before diffing two runs, drop the columns that change on every event but don't tell you anything, such as <code>ts</code>. Otherwise every job will look "changed". In <a href="https://findutils.com/convert/csv-column-extractor/">CSV Column Extractor</a>, keep <code>job_id</code>, <code>status</code>, <code>attempt</code> and <code>error.code</code>. Removing <code>user.id</code> here is also a small privacy win if the file is going outside your team.</p>
<h2>Step 4: diff run 1 against run 2 by key</h2>
<p>Do the same three steps for the re-run log (<code>run-2.ndjson</code>). Then open <a href="https://findutils.com/convert/csv-diff/">CSV Diff</a>, put run 1 in <strong>File A (before)</strong> and run 2 in <strong>File B (after)</strong>, and <strong>select</strong> <code>job_id</code> <strong>as the key column</strong>.</p>
<p>That last part matters. If you don't pick a key, the tool looks for a shared column named <code>id</code>, <code>email</code>, <code>sku</code>, <code>uuid</code>, <code>key</code> or <code>code</code>, and otherwise falls back to the first shared column whose values are unique in both files. <code>job_id</code> isn't on that list, so pick it yourself.</p>
<p>Because rows are matched by key rather than by line, a re-sorted file doesn't produce noise. For our two runs, the summary reads like this:</p>
<ul>
<li><p><strong>Added:</strong> <code>exp-103</code> (the job lost to the truncated line in run 1 completed in run 2)</p>
</li>
<li><p><strong>Changed:</strong> <code>exp-101</code> (<code>status</code> failed → done, <code>attempt</code> 1 → 2, <code>error.code</code> cleared) and <code>exp-104</code> (<code>attempt</code> 2 → 3, still <code>E_429</code>)</p>
</li>
<li><p><strong>Unchanged:</strong> <code>exp-102</code></p>
</li>
</ul>
<p>The <code>csv-diff.csv</code> download has a <code>change_type</code> column, the columns of File A plus any columns only File B has, and an <code>old_</code> column for each column that changed somewhere (<code>old_status</code>, <code>old_attempt</code>, <code>old_error.code</code>). There's also a ZIP with <code>added.csv</code>, <code>removed.csv</code> and <code>changed.csv</code>, which is easy to paste into a ticket.</p>
<p>One tip from the tool's docs: pick a key that identifies the same real thing in both files. If you match on something that can itself change, an edited row turns into one "removed" plus one "added".</p>
<h2>Step 5 (optional): back to NDJSON for a loader</h2>
<p>If the cleaned table needs to go into something that reads JSON Lines, such as a warehouse loader or a fine-tune dataset, flip the same <a href="https://findutils.com/convert/ndjson-to-csv/">converter</a> to <strong>CSV to NDJSON</strong>. Values stay strings unless you turn on <strong>Coerce types</strong>. With it on, <code>true</code>/<code>false</code>, <code>null</code> and plain numbers become JSON values, but a number with leading zeros like <code>007</code> stays text, and so does an integer of 2^53 or more. Dotted headers such as <code>user.id</code> stay flat keys. The tool deliberately doesn't rebuild nested objects.</p>
<h2>Checklist</h2>
<table>
<thead>
<tr>
<th>Step</th>
<th>Tool</th>
<th>Setting that matters</th>
</tr>
</thead>
<tbody><tr>
<td>Convert</td>
<td><a href="https://findutils.com/convert/ndjson-to-csv/">NDJSON → CSV</a></td>
<td>Flatten vs JSON cell. Read the skipped-line notes</td>
</tr>
<tr>
<td>Latest state</td>
<td><a href="https://findutils.com/convert/csv-dedupe/">CSV Dedupe</a></td>
<td>Key = <code>job_id</code>, keep <strong>Last row</strong></td>
</tr>
<tr>
<td>Trim</td>
<td><a href="https://findutils.com/convert/csv-column-extractor/">Column Extractor</a></td>
<td>Drop timestamps and personal fields</td>
</tr>
<tr>
<td>Compare</td>
<td><a href="https://findutils.com/convert/csv-diff/">CSV Diff</a></td>
<td>Pick the key yourself</td>
</tr>
<tr>
<td>Reload</td>
<td><a href="https://findutils.com/convert/ndjson-to-csv/">CSV → NDJSON</a></td>
<td>Coerce types only if the loader needs it</td>
</tr>
</tbody></table>
<p>Logs carry request paths, user IDs and sometimes tokens. The tool pages say the files stay in your browser (you can confirm that in DevTools' Network panel while converting), but the CSV you export is a normal file. Redact it before it goes into a shared drive.</p>
<p>For more depth on each step, FindUtils has guides on <a href="https://findutils.com/guides/ndjson-to-csv/">NDJSON to CSV</a>, <a href="https://findutils.com/guides/csv-dedupe/">deduping CSVs</a> and <a href="https://findutils.com/guides/csv-diff/">diffing CSVs</a>, plus a good read on <a href="https://findutils.com/blog/why-json-to-csv-to-sql-loses-data-types/">why JSON → CSV → SQL loses data types</a>.</p>
]]></content:encoded></item><item><title><![CDATA[From a JSON API Response to a Zod Schema + TypeScript Types in 5 Minutes]]></title><description><![CDATA[Paste a real payload, generate a Zod schema, infer types with z.infer, and validate at the boundary — without hand-writing every field.]]></description><link>https://findutils.hashnode.dev/from-a-json-api-response-to-a-zod-schema-typescript-types-in-5-minutes</link><guid isPermaLink="true">https://findutils.hashnode.dev/from-a-json-api-response-to-a-zod-schema-typescript-types-in-5-minutes</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[zod]]></category><category><![CDATA[json]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Fri, 16 Oct 2026 06:30:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/ebbd2955-37c7-4665-9496-b02c0b1257b5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You just got a third-party API key. The docs show a happy-path JSON blob and little else. Your TypeScript project needs both <strong>runtime validation</strong> (so a bad payload fails at the edge) and <strong>compile-time types</strong> (so autocomplete and <code>tsc</code> stay honest). Writing that by hand for a nested response is tedious — and easy to drift from reality when the API adds a field next month.</p>
<p>This walkthrough takes one realistic JSON response and turns it into a Zod schema plus inferred TypeScript types in a few minutes. You will see the full schema, how <code>z.infer</code> replaces a duplicate <code>interface</code>, and how to call <code>parse</code> / <code>safeParse</code> in a fetch wrapper. A note on tools: if you prefer not to type the schema by hand, browser converters such as FindUtils' <a href="https://findutils.com/developers/json-to-zod-schema/">JSON to Zod Schema</a> and <a href="https://findutils.com/developers/json-to-typescript/">JSON to TypeScript</a> run locally in the tab and do not upload your paste — useful when the sample contains customer emails or staging tokens.</p>
<h2>The sample payload</h2>
<p>Imagine a billing API that returns a customer, their open invoices, and a nested address. Copy this into a file named <code>sample.json</code> (or keep it in a scratch note):</p>
<pre><code class="language-json">{
  "id": "cus_9f3a2c1b",
  "email": "ada@example.com",
  "created_at": "2026-09-12T14:22:01.000Z",
  "plan": "pro",
  "is_active": true,
  "address": {
    "line1": "42 Computing Lane",
    "city": "Istanbul",
    "country": "TR",
    "postal_code": "34000"
  },
  "invoices": [
    {
      "id": "inv_1001",
      "amount_cents": 2900,
      "currency": "usd",
      "status": "paid",
      "hosted_url": "https://pay.example.com/inv/1001"
    },
    {
      "id": "inv_1002",
      "amount_cents": 2900,
      "currency": "usd",
      "status": "open",
      "hosted_url": "https://pay.example.com/inv/1002"
    }
  ],
  "tags": ["beta", "eu"]
}
</code></pre>
<p>Why this shape matters for generators and hand-written schemas alike:</p>
<ul>
<li>Strings that look like <strong>email</strong>, <strong>URL</strong>, and <strong>ISO datetime</strong> should become refinements (<code>z.string().email()</code>, <code>.url()</code>, <code>.datetime()</code>), not bare <code>z.string()</code>.</li>
<li>Nested <code>address</code> becomes <code>z.object({...})</code>.</li>
<li><code>invoices</code> is an array of objects — <code>z.array(z.object({...}))</code>.</li>
<li><code>tags</code> is a string array — <code>z.array(z.string())</code>.</li>
<li><code>amount_cents</code> is an integer; treat it as <code>z.number().int()</code> if your generator supports it.</li>
</ul>
<p>If you also need a language-agnostic contract for non-TypeScript consumers, generate a JSON Schema first with a <a href="https://findutils.com/developers/json-schema-generator/">JSON Schema generator</a>, then keep Zod as the TypeScript runtime layer. For the “what is JSON Schema even for?” background, the short guide <a href="https://findutils.com/guides/json-schema-validation-explained/">JSON Schema validation explained</a> is a useful companion.</p>
<h2>Step 1 — Decide: types only, or types + runtime?</h2>
<table>
<thead>
<tr>
<th>Need</th>
<th>Reach for</th>
<th>Trade-off</th>
</tr>
</thead>
<tbody><tr>
<td>Autocomplete and <code>tsc</code> only</td>
<td><code>interface</code> / <code>type</code>, or JSON → TypeScript</td>
<td>No protection if the API lies at runtime</td>
</tr>
<tr>
<td>Fail fast on bad payloads</td>
<td>Zod (or similar) + <code>z.infer</code></td>
<td>Small dependency; you write or generate a schema once</td>
</tr>
<tr>
<td>Shared contract across languages</td>
<td>JSON Schema</td>
<td>No TS inference unless you add a bridge</td>
</tr>
</tbody></table>
<p>For an untyped third-party API, <strong>Zod + <code>z.infer</code></strong> is usually the sweet spot: one source of truth for both layers.</p>
<h2>Step 2 — Generate (or write) the Zod schema</h2>
<p>Paste the JSON into a converter — for example <a href="https://findutils.com/developers/json-to-zod-schema/">JSON to Zod Schema</a> — and enable format detection if available. You should get something close to:</p>
<pre><code class="language-ts">import { z } from "zod";

export const InvoiceSchema = z.object({
  id: z.string(),
  amount_cents: z.number().int(),
  currency: z.string(),
  status: z.string(),
  hosted_url: z.string().url(),
});

export const AddressSchema = z.object({
  line1: z.string(),
  city: z.string(),
  country: z.string(),
  postal_code: z.string(),
});

export const CustomerSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  created_at: z.string().datetime(),
  plan: z.string(),
  is_active: z.boolean(),
  address: AddressSchema,
  invoices: z.array(InvoiceSchema),
  tags: z.array(z.string()),
});
</code></pre>
<blockquote>
<p><strong>Zod 4 note:</strong> the top-level string formats <code>z.email()</code>, <code>z.url()</code> and <code>z.iso.datetime()</code> are now preferred. The method forms used here (<code>z.string().email()</code> and friends) still work but are deprecated, so use whichever matches the Zod version in your project.</p>
</blockquote>
<p>Hand-tune after generation:</p>
<ol>
<li><strong>Tighten enums</strong> you know are closed: <code>status: z.enum(["paid", "open", "void"])</code>, <code>plan: z.enum(["free", "pro", "enterprise"])</code>.</li>
<li>Mark fields that are sometimes missing with <code>.optional()</code> or <code>.nullable()</code> — generators only see what you pasted.</li>
<li>Add <code>.describe()</code> or comments for teammates reviewing the PR.</li>
</ol>
<p>Prefer types without runtime checks for a quick prototype? Use <a href="https://findutils.com/developers/json-to-typescript/">JSON to TypeScript</a> for interfaces, then upgrade hot paths to Zod later. Many teams start with interfaces and promote boundary schemas once production traffic proves which fields lie.</p>
<h2>Step 3 — Infer TypeScript types from the schema</h2>
<p>Do <strong>not</strong> rewrite a parallel <code>interface Customer</code>. Derive it:</p>
<pre><code class="language-ts">export type Invoice = z.infer&lt;typeof InvoiceSchema&gt;;
export type Address = z.infer&lt;typeof AddressSchema&gt;;
export type Customer = z.infer&lt;typeof CustomerSchema&gt;;
</code></pre>
<p>Now <code>Customer["invoices"][number]["status"]</code> stays in sync when you change the schema. That sync is a big reason many TypeScript teams moved from hand-maintained interface + Yup pairs to Zod.</p>
<h2>Step 4 — Validate at the fetch boundary</h2>
<pre><code class="language-ts">async function fetchCustomer(id: string): Promise&lt;Customer&gt; {
  const res = await fetch(`https://api.example.com/customers/${id}`, {
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }

  const json: unknown = await res.json();
  return CustomerSchema.parse(json); // throws ZodError on mismatch
}
</code></pre>
<p>Prefer not to throw? Use <code>safeParse</code>:</p>
<pre><code class="language-ts">const result = CustomerSchema.safeParse(json);
if (!result.success) {
  console.error(result.error.flatten());
  throw new Error("Unexpected customer payload");
}
const customer: Customer = result.data;
</code></pre>
<p>Put this in one module (<code>api/customers.ts</code>). Callers get a typed <code>Customer</code> and never see raw <code>any</code>.</p>
<h2>Step 5 — Wire it into forms and server routes (optional)</h2>
<ul>
<li><strong>React Hook Form:</strong> <code>useForm({ resolver: zodResolver(CustomerSchema) })</code> via <code>@hookform/resolvers</code>.</li>
<li><strong>tRPC / Next.js Server Actions:</strong> <code>CustomerSchema.parse(input)</code> at the start of the procedure.</li>
<li><strong>Tests:</strong> assert fixtures with <code>expect(() =&gt; CustomerSchema.parse(fixture)).not.toThrow()</code>.</li>
</ul>
<p>The same schema that typed your client now guards the server.</p>
<h2>A five-minute checklist</h2>
<ol>
<li>Capture one <strong>real</strong> JSON response (happy path + one edge case if you can).</li>
<li>Generate Zod (or write the outer <code>z.object</code> and nest inward).</li>
<li>Tighten enums, optionals, and URL/email/datetime refinements.</li>
<li>Export <code>type X = z.infer&lt;typeof XSchema&gt;</code>.</li>
<li><code>parse</code> / <code>safeParse</code> in the single place that talks to the network.</li>
<li>(Optional) Keep a JSON Schema copy for OpenAPI or non-TS clients via a <a href="https://findutils.com/developers/json-schema-generator/">JSON Schema generator</a>.</li>
</ol>
<h2>Common pitfalls</h2>
<ul>
<li><strong>Sample too small.</strong> If every invoice is <code>"paid"</code>, a naive generator may not hint an enum — you still need domain knowledge.</li>
<li><strong>Numbers that arrive as strings.</strong> Some APIs send <code>"2900"</code>. Prefer <code>z.coerce.number().int()</code> only when you intentionally accept coercion.</li>
<li><strong>Dates as strings vs <code>Date</code>.</strong> Zod's <code>.datetime()</code> validates ISO strings; use <code>.transform((s) =&gt; new Date(s))</code> if you want <code>Date</code> objects downstream.</li>
<li><strong>Trusting <code>as Customer</code>.</strong> Type assertions silence the compiler without checking runtime shape — that is the bug Zod exists to catch.</li>
</ul>
<h2>Zod vs JSON Schema (quick orientation)</h2>
<p>Teams often ask whether they still need JSON Schema if they adopt Zod. Short answer: they solve overlapping but different jobs.</p>
<ul>
<li><strong>Zod</strong> lives in TypeScript. You validate at runtime and infer static types with <code>z.infer</code>. Ideal inside Node APIs, Next.js Server Actions, tRPC, and React forms.</li>
<li><strong>JSON Schema</strong> is language-agnostic JSON. Ideal when Python, Go, and TypeScript services must share one contract, or when an OpenAPI document is the source of truth.</li>
</ul>
<p>A practical pattern: keep JSON Schema (or OpenAPI) as the cross-language contract, and generate or maintain a Zod schema for the TypeScript edge. Browser helpers for both sides — <a href="https://findutils.com/developers/json-schema-generator/">JSON Schema generator</a> and <a href="https://findutils.com/developers/json-to-zod-schema/">JSON to Zod Schema</a> — make the first draft cheap. For the conceptual background on drafts, <code>$ref</code>, and validation errors, see <a href="https://findutils.com/guides/json-schema-validation-explained/">JSON Schema validation explained</a>.</p>
<p>If your world is TypeScript-only for the next year, Zod alone is enough. Add JSON Schema when a second language or an external partner needs the same shape.</p>
<h2>When browser converters help</h2>
<p>Hand-writing Zod for a 200-field OpenAPI dump is miserable. Pasting a redacted sample into a client-side converter gets you 80% of the schema in seconds; you spend the remaining time on enums and business rules. FindUtils' developer converters (<a href="https://findutils.com/developers/json-to-zod-schema/">Zod</a>, <a href="https://findutils.com/developers/json-to-typescript/">TypeScript</a>, <a href="https://findutils.com/developers/json-schema-generator/">JSON Schema</a>) are free, run in the browser, and are designed not to upload the JSON you paste on those pages — which matters when the sample is a staging customer record. Treat the output as a draft: always review before merging.</p>
<h2>Wrap-up</h2>
<p>Untyped JSON in, trusted <code>Customer</code> out: that is the whole job. Zod gives you runtime checks and <code>z.infer</code> types from one definition. Start from a real payload, generate or sketch the schema, tighten what the sample cannot teach you, and validate only at the boundary. Five minutes today saves a week of “why is <code>email</code> sometimes a number?” debugging later.</p>
<p><em>Want more free browser tools for JSON, APIs and everyday dev chores? They're all at <a href="https://findutils.com/">findutils.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Cookie-Free Analytics Explained: How Privacy-Friendly Analytics Count Visitors Without Cookies]]></title><description><![CDATA[Daily salted identifiers, aggregate reports, and what cookieless actually means, plus how DataVisitors approaches it (waitlist-only today).]]></description><link>https://findutils.hashnode.dev/cookie-free-analytics-explained-how-privacy-friendly-analytics-count-visitors-without-cookies</link><guid isPermaLink="true">https://findutils.hashnode.dev/cookie-free-analytics-explained-how-privacy-friendly-analytics-count-visitors-without-cookies</guid><category><![CDATA[analytics]]></category><category><![CDATA[privacy]]></category><category><![CDATA[webdev]]></category><category><![CDATA[#gdpr]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Wed, 14 Oct 2026 12:38:04 GMT</pubDate><content:encoded><![CDATA[<p>For years, “counting visitors” meant dropping a cookie (or three), stitching sessions across days, and hoping the consent banner was enough. A growing set of products take a different path: measure traffic with <strong>no cookie on the visitor by default</strong>, keep reports at the aggregate level, and treat person-level profiles as an explicit opt-in — not the starting point.</p>
<p>This piece has two layers. First, the <strong>general technique</strong> behind cookieless / privacy-friendly analytics (industry knowledge — not unique to any one vendor). Second, what <a href="https://datavisitors.com/">DataVisitors</a> documents about its own approach on its privacy, how-it-works, and product pages. Those two layers stay separate on purpose.</p>
<p><strong>Important:</strong> DataVisitors is <strong>waitlist-only</strong> right now. Accounts open by invitation. There is no “start a free trial” or “create an account now” path on the public site — join the <a href="https://datavisitors.com/waitlist">waitlist</a>, confirm the email, and wait for a seat. You can explore the <a href="https://datavisitors.com/demo">live demo</a> without an account.</p>
<p><em>Nothing in this article is legal advice. Cookie banners, consent, and ePrivacy rules depend on your jurisdiction, your other trackers, and how you configure the product. Talk to counsel for compliance decisions.</em></p>
<h2>Why cookies became the default (and why people want out)</h2>
<p>Classic web analytics often:</p>
<ol>
<li>Sets a first-party cookie with a stable visitor ID.</li>
<li>Reuses that ID across days and sometimes across subdomains.</li>
<li>Builds funnels, retention, and “who is this person?” views from that ID.</li>
</ol>
<p>That model is powerful. It is also exactly what many privacy laws and browser features push against: persistent identifiers on the device, cross-context tracking, and opaque third-party beacons. Cookieless analytics try to answer a narrower question well — <em>how many visits, from where, to which pages, completing which goals</em> — without needing a durable ID in the browser.</p>
<h2>The general technique: daily salted hashes (illustrative)</h2>
<p>A common cookieless pattern looks like this (simplified; <strong>illustrative</strong>, not a claim that any specific product uses this exact code):</p>
<pre><code class="language-js">// ILLUSTRATIVE ONLY — not DataVisitors source code
import crypto from 'node:crypto';

function dailyVisitorKey({ ip, userAgent, projectId, utcDate, dailySalt }) {
  // Salt rotates once per UTC day; projectId keeps sites from sharing IDs
  const material = [utcDate, dailySalt, projectId, ip, userAgent].join('|');
  return crypto.createHash('sha256').update(material).digest('hex');
}

// Count uniques inside one UTC day by inserting the key into a set/HLL
</code></pre>
<p>What this buys you:</p>
<ul>
<li><strong>Same person, same day, same site</strong> → same key → counts once toward daily uniques.</li>
<li><strong>Next UTC day</strong> → new salt → new key → you <strong>cannot</strong> stitch a continuous multi-day journey from the identifier alone.</li>
<li><strong>Same person on two projects</strong> → different <code>projectId</code> in the hash → two numbers, not one cross-site profile.</li>
</ul>
<p>Trade-offs (general industry knowledge):</p>
<table>
<thead>
<tr>
<th>You gain</th>
<th>You usually lose</th>
</tr>
</thead>
<tbody><tr>
<td>No cookie required for basic counting</td>
<td>Reliable cross-day “unique visitors” as one person</td>
</tr>
<tr>
<td>Harder to build a stalking-style profile from the analytics ID</td>
<td>Some retention / returning-user charts unless another consented ID exists</td>
</tr>
<tr>
<td>Smaller consent surface for <em>analytics-only</em> setups (still context-dependent)</td>
<td>Perfect bot resistance from the ID alone</td>
</tr>
</tbody></table>
<p>A rotating identifier is <strong>not</strong> a guarantee that all data is anonymous. IP addresses, referrers, and rare page paths can still be identifying in combination. Privacy-friendly products typically also drop or coarsen fields (e.g. country instead of precise location) and keep public share links at aggregate totals only.</p>
<h2>What DataVisitors says it does</h2>
<p>The following is taken from DataVisitors’ own pages — <a href="https://datavisitors.com/privacy-approach">privacy approach</a>, <a href="https://datavisitors.com/how-it-works">how it works</a>, <a href="https://datavisitors.com/faq">FAQ</a>, <a href="https://datavisitors.com/features/web-analytics">web analytics</a>, and integrations.</p>
<h3>No cookie on a visitor (by default)</h3>
<p>The privacy page states: <strong>“The tracking script sets no cookie. It starts stateless.”</strong> The FAQ repeats that the tracker starts stateless and that whether device storage is ever used depends on the project’s <strong>privacy regime</strong> and the visitor’s consent.</p>
<h3>A daily, project-scoped identifier</h3>
<p>Privacy approach: a visit is counted with <strong>“the day’s salt, minted once per UTC day.”</strong> The stored sample record describes the visitor as a <strong>“daily-salted hash, project-scoped.”</strong> Explicitly: <strong>“One person on two sites is two numbers”</strong> because the project is inside the hash — visitors cannot be joined across sites via that identifier.</p>
<p>FAQ phrasing: <em>“Stateless measurement uses a daily identifier that differs between projects. Device storage depends on the project's privacy mode and the visitor's consent. A rotating identifier does not guarantee that all data is anonymous.”</em> That last sentence is worth quoting in full — it matches the honest industry caveat above.</p>
<h3>Privacy regimes you choose per project</h3>
<p>DataVisitors documents three regimes (<a href="https://datavisitors.com/privacy-approach">privacy approach</a>):</p>
<ul>
<li><strong>Automatic (default):</strong> Stateless where the law is strict; device storage elsewhere when allowed.</li>
<li><strong>Strict:</strong> Nothing stored on the device anywhere until your site’s consent allows it; until then every visit uses the daily salt only.</li>
<li><strong>Relaxed (opt-out):</strong> A first-party id may be kept on the device by default; Global Privacy Control, Do Not Track, and an opt-out call are honoured.</li>
</ul>
<p>The page also says Global Privacy Control and Do Not Track are respected in all regimes; only an explicit consent from your own banner outranks them. In the EEA, UK, Switzerland, or an unresolved country, nothing is stored on the device until your site’s consent allows it (Automatic / Strict behaviour as documented).</p>
<h3>Aggregates first; People off by default</h3>
<p>Public share links show <strong>aggregates only</strong>, never an individual visitor. The <strong>People</strong> feature stays closed until an administrator opens it, and switching it on is recorded. Exports, erasure, and account closure are described as available on every plan — erasure removes a person’s events and traits and keeps a hashed tombstone.</p>
<h3>What you can measure (web analytics)</h3>
<p>The <a href="https://datavisitors.com/features/web-analytics">web analytics</a> feature page describes visitors, page views, sessions, sources (referrer / channel / UTM), pages, engagement (bounce, duration, scroll depth), goals, bot/referrer-spam filtering, and an <strong>AI assistants</strong> channel for recognized referrers such as ChatGPT, Claude, Perplexity, Gemini, and Copilot. It also clarifies visitor vs session vs page view, and warns that dual-counting page views from both browser and server will inflate totals.</p>
<h3>How you connect it (when invited)</h3>
<p><a href="https://datavisitors.com/how-it-works">How it works</a> and the <a href="https://datavisitors.com/integrations/browser">browser integration</a> document a deferred snippet shaped like:</p>
<pre><code class="language-html">&lt;script
  defer
  src="https://api.datavisitors.com/assets/sg.js"
  data-key="YOUR-INSTALL-KEY"
&gt;&lt;/script&gt;
</code></pre>
<p>Set allowed origins and privacy settings <strong>before</strong> the first event. Use one source for page views (browser <em>or</em> server, not both for the same load). Server-side and webhook events go through the <a href="https://datavisitors.com/integrations/api">HTTP API</a> with separate ingest and read keys.</p>
<h3>Pricing context (no “start now”)</h3>
<p><a href="https://datavisitors.com/pricing">Pricing</a> lists Free / Starter / Growth / Business with event ceilings and feature gates, but prices are marked <strong>“not published yet.”</strong> Every plan CTA is <strong>Join the waitlist</strong>. Past an events ceiling, the site says collection continues; the plan gates <em>reading</em>. That is product documentation, not an invitation to sign up today.</p>
<h2>How this compares to “just turn off cookies in GA”</h2>
<p>Flipping a cookieless flag in a cookie-first product is not the same architecture as designing aggregates + daily salts from day one. Cookie-first tools often still lean on device storage when allowed, and their mental model remains “user ID → journey.” Privacy-first tools push you toward questions the aggregate can answer: which sources convert, which pages bounce, whether a release moved the needle — and they make person-level data a deliberate switch.</p>
<p>If you also run free browser-only utilities for day-to-day debugging (headers, JSON, PDF chores), that is a separate trust story from analytics — <a href="https://findutils.com/">FindUtils</a> keeps those tools client-side. Analytics still needs a server somewhere to store counts; the privacy question is <em>what</em> you store and <em>how</em> you identify a visit.</p>
<h2>Practical checklist if you are evaluating cookieless analytics</h2>
<ol>
<li><strong>Read the privacy page, not only the homepage.</strong> Look for: cookies or not, salt rotation period, cross-site joining, consent behaviour, GPC/DNT.</li>
<li><strong>Decide your regime before the first event.</strong> Changing later does not rewrite history the way you might hope.</li>
<li><strong>One page-view source.</strong> Dual instrumentation doubles page views.</li>
<li><strong>Goals over vanity uniques.</strong> A signup goal survives cookieless limits better than “users this month” marketing slides.</li>
<li><strong>Ask what public links expose.</strong> Aggregate-only share URLs are a strong signal.</li>
<li><strong>For DataVisitors specifically:</strong> explore the <a href="https://datavisitors.com/demo">demo</a>, read <a href="https://datavisitors.com/privacy-approach">privacy</a> and <a href="https://datavisitors.com/faq">FAQ</a>, then <a href="https://datavisitors.com/waitlist">join the waitlist</a>. Do not expect a self-serve signup today.</li>
</ol>
<h2>Wrap-up</h2>
<p>Cookie-free analytics usually means: <strong>no cookie by default</strong>, a <strong>short-lived or salted identifier</strong> for same-day counting, <strong>project scoping</strong> so IDs do not federate across sites, and <strong>aggregates as the default report</strong>. DataVisitors documents exactly that pattern — daily-salted, project-scoped hashes; three privacy regimes; People off until an admin enables it; waitlist-gated accounts.</p>
<p>Start at <a href="https://datavisitors.com/">datavisitors.com</a>, dig into the <a href="https://datavisitors.com/privacy-approach">privacy approach</a> and <a href="https://datavisitors.com/how-it-works">how it works</a>, try the <a href="https://datavisitors.com/demo">demo</a>, and join the <a href="https://datavisitors.com/waitlist">waitlist</a> if you want a seat when invitations open.</p>
]]></content:encoded></item><item><title><![CDATA[Add an Emoji Favicon with One Line of HTML (Next.js, Astro, Vue, WordPress) Plus Dynamic Tab Notifications]]></title><description><![CDATA[Wire 🔥 or 🚀 into your tab with a single link tag, then swap it at runtime with the EmojiFavicons SDK.]]></description><link>https://findutils.hashnode.dev/add-an-emoji-favicon-with-one-line-of-html-next-js-astro-vue-wordpress-plus-dynamic-tab-notifications</link><guid isPermaLink="true">https://findutils.hashnode.dev/add-an-emoji-favicon-with-one-line-of-html-next-js-astro-vue-wordpress-plus-dynamic-tab-notifications</guid><category><![CDATA[webdev]]></category><category><![CDATA[HTML]]></category><category><![CDATA[frontend]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Wed, 14 Oct 2026 12:37:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/8dd10d15-6d65-4e2a-8ddd-2dcf925e48ea.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Favicons used to mean a ZIP of PNGs, an ICO, an apple-touch icon, and a half-remembered <code>manifest.json</code>. For a lot of sites, that is still the right approach. For many others — a side project, a docs site, a staging deploy, a dashboard that needs a notification badge — you only need one emoji in the tab, and you need it <em>today</em>.</p>
<p><a href="https://emojifavicons.com/">EmojiFavicons</a> is a URL-based emoji favicon service from FindUtils. You point a normal <code>&lt;link rel="icon"&gt;</code> at an emoji slug, and the API returns an SVG (under 200 bytes for a plain emoji, per the docs). No signup, no API key, no build step. This article sticks to what the site documents and what the endpoints actually return when you request them.</p>
<h2>The one-line install</h2>
<p>Drop this in your HTML <code>&lt;head&gt;</code>:</p>
<pre><code class="language-html">&lt;link rel="icon" href="https://emojifavicons.com/fire"&gt;
</code></pre>
<p>Replace <code>fire</code> with any emoji name. The homepage and <a href="https://emojifavicons.com/docs">docs</a> state that EmojiFavicons covers <strong>3,773</strong> Unicode emojis, searchable by <strong>11,550</strong> keywords. Names and keywords are case-insensitive. You can also use a keyword (<code>/hot</code> resolves to fire), a URL-encoded emoji character, or a country code for flags (<code>/US</code>, <code>/TR</code>).</p>
<p>A live request to <code>https://emojifavicons.com/fire</code> returns <code>image/svg+xml</code> — a tiny SVG wrapping the 🔥 glyph. Safari is a special case: the <a href="https://emojifavicons.com/how-it-works">how-it-works</a> page explains that Safari has limited SVG favicon support, so the service auto-serves a PNG (Twemoji) for Safari User-Agents. You can force a format with <code>?svg</code> or <code>?png</code>.</p>
<p>If you later want a classic downloadable package instead of a live URL, the docs also expose <code>GET /download/:emoji</code> — for example <code>https://emojifavicons.com/download/rocket</code> returns a ZIP of standard sizes. That is the honest self-host path when you do not want a third-party dependency at runtime.</p>
<h2>Customize with URL parameters</h2>
<p>Every favicon endpoint accepts query parameters. A documented example that works today:</p>
<pre><code class="language-text">https://emojifavicons.com/fire?size=64&amp;bg=1a1a1a&amp;animate=pulse
</code></pre>
<p>That request returns <code>image/svg+xml</code> with a 64×64 viewBox, a dark background, and a CSS <code>pulse</code> animation. Useful parameters from the docs (not an exhaustive list):</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>What it does (per docs)</th>
</tr>
</thead>
<tbody><tr>
<td><code>size</code></td>
<td>SVG size in pixels (16–512; default 48)</td>
</tr>
<tr>
<td><code>bg</code></td>
<td>Background hex without <code>#</code></td>
</tr>
<tr>
<td><code>shape</code></td>
<td>e.g. <code>circle</code>, <code>rounded</code>, <code>hexagon</code>, …</td>
</tr>
<tr>
<td><code>animate</code></td>
<td>e.g. <code>pulse</code>, <code>bounce</code>, <code>spin</code>, and many others</td>
</tr>
<tr>
<td><code>badge</code></td>
<td>Notification overlay: <code>1</code>–<code>99</code> or <code>dot</code></td>
</tr>
<tr>
<td><code>theme</code></td>
<td>Branded presets such as <code>github-dark</code>, <code>vercel-dark</code>, <code>tailwind</code></td>
</tr>
<tr>
<td><code>png</code> / <code>svg</code></td>
<td>Force output format</td>
</tr>
</tbody></table>
<p>Combine freely. Combos put 2–10 emojis side by side with a <code>+</code>:</p>
<pre><code class="language-html">&lt;link rel="icon" href="https://emojifavicons.com/fire+rocket"&gt;
</code></pre>
<p>That endpoint returns a single SVG with 🔥 and 🚀 next to each other — useful for “shipping fast” vibes without designing an icon.</p>
<p>For a red unread count without JavaScript:</p>
<pre><code class="language-html">&lt;link rel="icon" href="https://emojifavicons.com/bell?badge=3"&gt;
</code></pre>
<h2>Framework installs (verified against the official guides)</h2>
<p>The <a href="https://emojifavicons.com/frameworks">frameworks index</a> lists 19 tutorials. Below are the four most common, copied from the site’s own guides. In every case, remove or rename any default <code>favicon.ico</code> / <code>favicon.svg</code> in <code>public/</code> so the external URL wins.</p>
<h3>Next.js (App Router)</h3>
<p>From the <a href="https://emojifavicons.com/emoji-favicon-for-nextjs">Next.js guide</a>:</p>
<pre><code class="language-ts">// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  icons: {
    icon: 'https://emojifavicons.com/rocket',
    apple: 'https://emojifavicons.com/apple-touch-icon/rocket',
  },
};
</code></pre>
<p>Pages Router: put the <code>&lt;link&gt;</code> tags in <code>pages/_document.tsx</code> inside <code>&lt;Head&gt;</code>. The guide notes that Vercel may cache an old favicon for up to 24 hours; append <code>?v=2</code> if you need a cache bust.</p>
<h3>Astro</h3>
<p>From the <a href="https://emojifavicons.com/emoji-favicon-for-astro">Astro guide</a>, in your shared layout:</p>
<pre><code class="language-astro">&lt;head&gt;
  &lt;link rel="icon" type="image/svg+xml" href="https://emojifavicons.com/rocket" /&gt;
  &lt;link rel="apple-touch-icon" href="https://emojifavicons.com/apple-touch-icon/rocket" /&gt;
&lt;/head&gt;
</code></pre>
<p>Delete <code>public/favicon.svg</code> if the starter left one. Keep the tags in the persistent layout so View Transitions do not thrash the tab icon.</p>
<h3>Vue 3 + Vite / Nuxt 3</h3>
<p>From the <a href="https://emojifavicons.com/emoji-favicon-for-vue">Vue guide</a>:</p>
<p><strong>Vite:</strong> edit root <code>index.html</code>:</p>
<pre><code class="language-html">&lt;link rel="icon" type="image/svg+xml" href="https://emojifavicons.com/rocket" /&gt;
&lt;link rel="apple-touch-icon" href="https://emojifavicons.com/apple-touch-icon/rocket" /&gt;
</code></pre>
<p><strong>Nuxt 3:</strong> in <code>nuxt.config.ts</code>:</p>
<pre><code class="language-ts">export default defineNuxtConfig({
  app: {
    head: {
      link: [
        { rel: 'icon', type: 'image/svg+xml', href: 'https://emojifavicons.com/rocket' },
        { rel: 'apple-touch-icon', href: 'https://emojifavicons.com/apple-touch-icon/rocket' },
      ],
    },
  },
});
</code></pre>
<h3>WordPress</h3>
<p>From the <a href="https://emojifavicons.com/emoji-favicon-for-wordpress">WordPress guide</a>, append to the child theme’s <code>functions.php</code> (or a site-wide snippets plugin):</p>
<pre><code class="language-php">add_action('wp_head', function () {
  echo '&lt;link rel="icon" href="https://emojifavicons.com/rocket" type="image/svg+xml"&gt;';
  echo '&lt;link rel="apple-touch-icon" href="https://emojifavicons.com/apple-touch-icon/rocket"&gt;';
});
</code></pre>
<p>Clear the Customizer Site Icon if WordPress still serves its own. Hard-refresh after saving.</p>
<h2>Dynamic tab notifications with the JS SDK</h2>
<p>Static badges are fine for marketing sites. Apps need the tab to react. Include the SDK once:</p>
<pre><code class="language-html">&lt;script src="https://emojifavicons.com/sdk.js"&gt;&lt;/script&gt;
</code></pre>
<p><code>https://emojifavicons.com/sdk.js</code> returns <code>application/javascript</code>. The homepage and docs show the core surface; the script itself exposes:</p>
<pre><code class="language-js">EmojiFavicon.set('rocket');
EmojiFavicon.animate('fire', 'pulse');
EmojiFavicon.notify('bell', 3000); // flash, then restore
EmojiFavicon.restore();
EmojiFavicon.setBadge('bell', 5);
EmojiFavicon.clearBadge();
</code></pre>
<p>A practical pattern for “you have new messages while the tab is in the background”:</p>
<pre><code class="language-js">function onUnread(count) {
  if (count &gt; 0) {
    EmojiFavicon.set('bell', { badge: Math.min(count, 99) });
  } else {
    EmojiFavicon.restore();
  }
}
</code></pre>
<p><code>notify(emoji, durationMs)</code> temporarily swaps the icon, then restores the previous <code>href</code> — handy for a one-shot alert without managing state yourself.</p>
<h2>Trade-offs (be honest)</h2>
<p><strong>Depends on a third-party URL.</strong> If emojifavicons.com is unreachable, the tab falls back to whatever the browser does without a favicon. The <a href="https://emojifavicons.com/compare">compare</a> page itself lists this as a con of URL-based services, mitigated by CDN caching (docs: 7-day CDN cache, 24-hour browser cache) and by downloading the SVG/PNG/ICO (or the ZIP from <code>/download/:emoji</code>) and hosting it yourself.</p>
<p><strong>Rate limits.</strong> The FAQ on the homepage says normal use has a <strong>300 requests per minute</strong> limit, with no signup or credit card required. That is generous for a favicon, but it is still a shared free resource — do not hammer it from a CI loop that rebuilds thousands of pages per minute.</p>
<p><strong>Not a full custom-image favicon studio.</strong> If you need a hand-drawn mark or a photo crop, generate and self-host assets instead. For that workflow, <a href="https://findutils.com/design/favicon-generator/">FindUtils’ favicon generator</a> is a useful browser-side companion when you already have an image file. EmojiFavicons and the FindUtils generator solve different problems: emoji-as-URL vs. image-to-package.</p>
<p><strong>Caching surprises.</strong> CDNs and browsers cache favicons aggressively. After a change, hard-refresh or bump a query string.</p>
<h2>Quick health check</h2>
<p>Before you ship, run your domain through the <a href="https://emojifavicons.com/check">Favicon Health Checker</a>. It scores icon / SVG / apple-touch / manifest coverage and offers copy-paste fixes. Pair that with the interactive <a href="https://emojifavicons.com/generator">generator</a> if you want to preview size, shape, animation, and badge before pasting a URL into production.</p>
<h2>Wrap-up</h2>
<ol>
<li><p>Add <code>&lt;link rel="icon" href="https://emojifavicons.com/fire"&gt;</code> (or your emoji of choice).</p>
</li>
<li><p>Optionally tune with <code>?size=</code>, <code>?bg=</code>, <code>?animate=</code>, <code>?badge=</code>, or a combo like <code>/fire+rocket</code>.</p>
</li>
<li><p>Follow the framework guide for <a href="https://emojifavicons.com/emoji-favicon-for-nextjs">Next.js</a>, <a href="https://emojifavicons.com/emoji-favicon-for-astro">Astro</a>, <a href="https://emojifavicons.com/emoji-favicon-for-vue">Vue</a>, or <a href="https://emojifavicons.com/emoji-favicon-for-wordpress">WordPress</a>.</p>
</li>
<li><p>For runtime swaps, load <code>sdk.js</code> and call <code>set</code> / <code>animate</code> / <code>notify</code>.</p>
</li>
<li><p>If you outgrow a third-party URL, download the ZIP and self-host.</p>
</li>
</ol>
<p>Start at <a href="https://emojifavicons.com/">emojifavicons.com</a>, browse the <a href="https://emojifavicons.com/docs">docs</a>, and if you need more free browser-only utilities beyond favicons, <a href="https://findutils.com/">FindUtils</a> has a large catalog of client-side tools that never upload your files.</p>
]]></content:encoded></item><item><title><![CDATA[Why Is My Email Going to Spam? A Browser-Only Triage for SPF, DKIM, DMARC, PTR and Blocklists]]></title><description><![CDATA[A calm, step-by-step way to find out why your mail lands in junk, using nothing but a browser.]]></description><link>https://findutils.hashnode.dev/why-is-my-email-going-to-spam-a-browser-only-triage-for-spf-dkim-dmarc-ptr-and-blocklists</link><guid isPermaLink="true">https://findutils.hashnode.dev/why-is-my-email-going-to-spam-a-browser-only-triage-for-spf-dkim-dmarc-ptr-and-blocklists</guid><category><![CDATA[dns]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[email]]></category><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Wed, 14 Oct 2026 08:54:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6acdd1ff3592b8a0607a0262/5cf2f728-a452-485e-9252-77a61dcb5e7e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Few things are as quietly maddening as a perfectly normal email landing in someone's spam folder. A password reset, an invoice, a reply to a customer: gone, filed next to fake lottery wins. The good news is that most deliverability problems come down to a small set of DNS records and reputation signals, and you can check nearly all of them from a browser in about fifteen minutes.</p>
<p>This guide walks through the triage in the order I'd actually do it. No terminal, no paid dashboards. Just evidence first, then fixes.</p>
<h2>Step 0: Get one real message that went to spam</h2>
<p>Don't start by guessing at DNS. Start with a message that actually failed. Ask the recipient to forward it as an attachment, or send a test to a Gmail or Outlook account you control and find it in Junk.</p>
<p>Then open the full headers ("Show original" in Gmail, "View message source" in Outlook). This raw text is the single most useful thing you'll look at today, because it contains the receiving server's own verdict.</p>
<h2>Step 1: Read the headers before touching anything</h2>
<p>Paste the raw headers into the <a href="https://findutils.com/network/email-header-analyzer/">Email Header Analyzer</a>. It traces the routing path hop by hop, lists the IP address of each server along the way, and pulls out the SPF, DKIM and DMARC results.</p>
<p>Look for the <code>Authentication-Results</code> line. You'll typically see something like <code>spf=pass</code>, <code>dkim=fail</code>, <code>dmarc=fail</code>. That tells you where to dig:</p>
<ul>
<li><strong>All three pass, still spam?</strong> The problem is probably reputation or content, not authentication. Skip ahead to Steps 5 and 6.</li>
<li><strong>SPF fails:</strong> the sending IP isn't authorized for your domain.</li>
<li><strong>DKIM fails or is missing:</strong> the message wasn't signed, or the signature doesn't match the published key.</li>
<li><strong>DMARC fails:</strong> neither SPF nor DKIM passed <em>in alignment</em> with the From domain (more on that below).</li>
</ul>
<p>Also write down the IP of the first external hop, the server that actually handed the mail to the internet. You'll need it for the PTR and blocklist checks.</p>
<h2>Step 2: Check SPF</h2>
<p><strong>What it is:</strong> SPF (Sender Policy Framework) is a TXT record on your domain that lists which servers are allowed to send mail for it. It looks like this:</p>
<pre><code>v=spf1 include:_spf.google.com include:sendgrid.net -all
</code></pre>
<p>Run your domain through the <a href="https://findutils.com/security/email-security-checker/">Email Security Checker</a>, which inspects SPF, DKIM, DMARC, MTA-STS, TLS-RPT and BIMI in one pass. If you want to see the raw records yourself, the <a href="https://findutils.com/network/dns-lookup/">DNS Lookup</a> tool will show every TXT record on the domain.</p>
<p><strong>Common SPF mistakes:</strong></p>
<ul>
<li><strong>Two SPF records.</strong> You're allowed exactly one TXT record starting with <code>v=spf1</code>. Adding a second one when you sign up for a new service is the most common way people break SPF. Merge them into one.</li>
<li><strong>Too many lookups.</strong> SPF has a limit of 10 DNS lookups (each <code>include</code>, <code>a</code>, <code>mx</code> and <code>redirect</code> counts, and nested includes count too). Go over and receivers return a <code>permerror</code>, which often behaves like a fail.</li>
<li><strong>Forgetting a sender.</strong> Your newsletter tool, your CRM, your helpdesk, the billing system: every service that sends as your domain needs to be covered, or needs to send from a subdomain with its own record.</li>
<li><strong>Using <code>+all</code>.</strong> This authorizes the entire internet. Never do it. Use <code>~all</code> (soft fail) while testing and <code>-all</code> once you're confident.</li>
</ul>
<h2>Step 3: Check DKIM</h2>
<p><strong>What it is:</strong> DKIM adds a cryptographic signature to each message. The receiving server fetches your public key from DNS at <code>selector._domainkey.yourdomain.com</code> and verifies the signature.</p>
<p>The selector is in the <code>DKIM-Signature</code> header of your message (the <code>s=</code> value). Look up that exact name as a TXT record with <a href="https://findutils.com/network/dns-lookup/">DNS Lookup</a>, or let the Email Security Checker find it.</p>
<p><strong>Common DKIM mistakes:</strong></p>
<ul>
<li><strong>Signing is never turned on.</strong> Many providers require you to publish the key <em>and</em> click an "enable" or "authenticate" button. Publishing the record alone does nothing.</li>
<li><strong>Broken key formatting.</strong> Long keys are sometimes split into multiple strings, and some DNS panels add stray quotes or line breaks. A single missing character invalidates the key.</li>
<li><strong>Signing with the provider's domain.</strong> If the signature says <code>d=mailprovider.net</code> instead of <code>d=yourdomain.com</code>, DKIM may "pass" but won't align for DMARC.</li>
</ul>
<h2>Step 4: Check DMARC</h2>
<p><strong>What it is:</strong> DMARC is a TXT record at <code>_dmarc.yourdomain.com</code> that tells receivers what to do when a message fails authentication, and where to send reports:</p>
<pre><code>v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com
</code></pre>
<p>The key idea is <strong>alignment</strong>. DMARC passes only if SPF or DKIM passes <em>and</em> the domain they validated matches the domain in the visible From address. This is why a message can show <code>spf=pass</code> and still fail DMARC: SPF validated your email provider's bounce domain, not yours.</p>
<p><strong>Common DMARC mistakes:</strong></p>
<ul>
<li><strong>No record at all.</strong> Major mailbox providers now expect bulk senders to have DMARC. Even <code>p=none</code> is better than nothing.</li>
<li><strong>Jumping straight to <code>p=reject</code>.</strong> If you haven't confirmed every legitimate sender aligns, you'll block your own mail. Start at <code>none</code>, read the reports, move to <code>quarantine</code>, then <code>reject</code>.</li>
<li><strong>Reports going nowhere.</strong> A <code>rua</code> address that no one reads means you're flying blind.</li>
</ul>
<p>For a broader look at your domain's email-related DNS (MX, SPF, DKIM, DMARC together), the <a href="https://findutils.com/network/dns-security-scanner/">DNS Security Scanner</a> gives a useful second opinion.</p>
<h2>Step 5: Check reverse DNS (PTR)</h2>
<p><strong>What it is:</strong> A PTR record maps an IP address back to a hostname. Receiving servers check that your sending IP has one, and ideally that the hostname resolves back to the same IP (forward-confirmed reverse DNS).</p>
<p>Take the sending IP from Step 1 and run it through the <a href="https://findutils.com/network/reverse-dns-lookup/">Reverse DNS Lookup</a>.</p>
<ul>
<li>If you use Google Workspace, Microsoft 365, or a major sending service, PTR is their job, and it's almost always fine.</li>
<li>If you run your own mail server or send from a VPS, a missing or generic PTR (something like <code>123-45-67-89.static.isp.net</code>) is a classic spam trigger. PTR records are set by whoever owns the IP block, so you'll usually configure it in your hosting provider's panel, not your DNS provider's.</li>
</ul>
<h2>Step 6: Check blocklists</h2>
<p>If authentication passes and PTR looks right, reputation is the next suspect. Put the sending IP into the <a href="https://findutils.com/network/ip-blacklist-checker/">IP Blacklist Checker</a>, which queries five DNS-based blocklists (Barracuda, SpamCop, UCEPROTECT, JustSpam and PSBL) in one go.</p>
<p>Be honest with yourself about the results:</p>
<ul>
<li><strong>On a shared sending service?</strong> A listing on a shared IP is the provider's problem to solve. Open a ticket with them.</li>
<li><strong>On your own server?</strong> Find out <em>why</em> before requesting delisting. A compromised account, an open form that sends mail, or a purchased list will get you relisted within days.</li>
<li><strong>Clean everywhere and still in spam?</strong> Five lists aren't the whole picture. Big providers like Gmail and Microsoft keep their own internal reputation data, and content, engagement and sending volume all matter. Check their postmaster tools for your domain.</li>
</ul>
<h2>Step 7: Re-check after you change DNS</h2>
<p>DNS changes aren't instant. After editing a record, use the <a href="https://findutils.com/network/dns-propagation-checker/">DNS Propagation Checker</a> to confirm resolvers around the world see the new value before you conclude a fix "didn't work." Then send a fresh test message and read the headers again. That's the only verdict that really counts.</p>
<h2>The quick checklist</h2>
<ul>
<li> Got the raw headers from a real message that went to spam</li>
<li> Read <code>Authentication-Results</code>: which of SPF, DKIM, DMARC failed?</li>
<li> Exactly <strong>one</strong> SPF record, under 10 lookups, covering every sender, ending in <code>~all</code> or <code>-all</code></li>
<li> DKIM enabled at every sending service, signing with <strong>your</strong> domain (<code>d=yourdomain.com</code>)</li>
<li> DMARC record exists at <code>_dmarc</code>, with a <code>rua</code> address someone actually reads</li>
<li> SPF or DKIM <strong>aligns</strong> with the From domain</li>
<li> Sending IP has a PTR that resolves back to the same IP</li>
<li> Sending IP isn't on any blocklist (and you know why if it was)</li>
<li> DNS changes have propagated before you re-test</li>
<li> Sent a fresh test and confirmed the new headers say <code>pass</code></li>
</ul>
<h2>A final word</h2>
<p>Deliverability problems feel mysterious, but they rarely are. In most cases the headers tell you exactly what failed, and the fix is a single DNS record: a duplicate SPF entry, a DKIM key that never got enabled, a missing DMARC policy. Work through it in order, change one thing at a time, and verify with a real message after each change.</p>
<hr />
<p><em>All the tools linked above are part of <a href="https://findutils.com/">FindUtils</a>, a collection of 463 free tools that run in your browser. No sign-up, no installs.</em></p>
]]></content:encoded></item><item><title><![CDATA[JSON, PDF and Image Chores in the Browser: A No-Upload Workflow Guide]]></title><description><![CDATA[Most of my "five-minute tasks" are not coding. They are chores: an API response that won't parse, a contract that has to go out as one PDF, a batch of…]]></description><link>https://findutils.hashnode.dev/json-pdf-and-image-chores-in-the-browser-a-no-upload-workflow-guide</link><guid isPermaLink="true">https://findutils.hashnode.dev/json-pdf-and-image-chores-in-the-browser-a-no-upload-workflow-guide</guid><dc:creator><![CDATA[FindUtils]]></dc:creator><pubDate>Tue, 13 Oct 2026 06:43:30 GMT</pubDate><content:encoded><![CDATA[<p>Most of my "five-minute tasks" are not coding. They are chores: an API response that won't parse, a contract that has to go out as one PDF, a batch of iPhone photos a client can't open. The usual fix is to search for "json formatter online", click the first result, and paste in something you shouldn't.</p>
<p>I built <a href="https://findutils.com">FindUtils</a> partly because of that habit. It is a free toolbox of single-purpose browser tools: 463 at the time of writing. The core tools need no account and no install. This post is less about the site and more about a workflow: how to get through common JSON, PDF and image chores without handing your files to a stranger's server, and how to check that claim for any tool, including mine.</p>
<h2>First, how do you know a tool doesn't upload?</h2>
<p>"Runs in your browser" is a claim, so test it before you trust it:</p>
<ol>
<li><strong>Open DevTools → Network</strong> before you drop the file in. Clear the log, run the tool, and look for a request whose size matches your file. A client-side tool may still load scripts, fonts, analytics or ads, but none of those requests should carry your data.</li>
<li><strong>Read the page's own data note.</strong> A good tool says what leaves the device. On FindUtils, file tools that run locally say so on the page. Lookup tools (DNS, WHOIS, SSL) send the domain you type to the service named on the page, because there's no other way to do a lookup.</li>
<li><strong>Remember what local processing doesn't cover.</strong> You still have to trust the page's code, your browser extensions and your device. Client-side is a smaller trust surface, not zero trust.</li>
</ol>
<p>With that settled, here's the workflow.</p>
<h2>JSON: from "invalid" to typed code</h2>
<p><strong>1. Fix and read it: <a href="https://findutils.com/developers/json-formatter/">JSON Formatter</a></strong>
Paste the payload. The error message gives you the line and column. <strong>Auto-Fix</strong> makes a conservative repair of the three usual suspects: trailing commas, single quotes and unquoted keys. <strong>Format</strong> pretty-prints with 2, 4 or 8 spaces, <strong>Minify</strong> compacts it, and <strong>Tree</strong> gives you a collapsible view for deeply nested responses. On the page, the JSON is processed in your browser.</p>
<p><strong>2. Compare two versions: <a href="https://findutils.com/developers/json-diff/">JSON Diff</a></strong>
When staging and production return "the same" response but something breaks, a line diff of pretty-printed JSON is noisy. The default view lists semantic paths (added, removed, modified), so <code>user.address.zip</code> showing as <em>modified</em> tells you more than 40 red and green lines. You can switch to a unified, git-style view when you need it.</p>
<p><strong>3. Turn it into types: <a href="https://findutils.com/developers/json-to-typescript/">JSON to TypeScript</a></strong>
Paste a real response and get interfaces or type aliases. It's a better starting point than typing the shape by hand, but review optional fields. A sample can't tell you which keys are sometimes missing. There are siblings for <a href="https://findutils.com/developers/json-to-zod-schema/">Zod</a>, <a href="https://findutils.com/developers/json-to-go-struct/">Go structs</a> and <a href="https://findutils.com/developers/json-to-python-class/">Python classes</a>.</p>
<p><strong>4. Hand it to a spreadsheet person: <a href="https://findutils.com/convert/json-to-csv/">JSON ↔ CSV</a></strong>
Both directions work on one page, with nested objects, quoted commas and custom delimiters handled. One caution: CSV has no types. <code>"00123"</code> and <code>123</code> look the same once they're in a cell, so don't round-trip JSON through CSV and expect the original back.</p>
<p><strong>Bonus: <a href="https://findutils.com/developers/jwt-decoder/">JWT Decoder</a>.</strong> A JWT is often a live credential, so pasting one into a random decoder is a real risk. This one decodes and verifies HS256/384/512 and RS256 (with a PEM key) in the browser, and the page states that secrets stay on the page.</p>
<h2>PDF: merge, number, clean, in that order</h2>
<p>The order matters more than the tools.</p>
<p><strong>1. <a href="https://findutils.com/pdf/pdf-merger/">Merge</a></strong> the files and drag them into sequence. The merge runs in the browser with the open-source pdf-lib library. If you only need part of a document, run <a href="https://findutils.com/pdf/pdf-split/">Split</a> first.</p>
<p><strong>2. <a href="https://findutils.com/pdf/pdf-page-numbers/">Add page numbers</a></strong> <em>after</em> merging. If you number each file first, you get "1, 2, 3, 1, 2" in the final document. One numbering pass on the merged file gives one sequence.</p>
<p><strong>3. <a href="https://findutils.com/pdf/pdf-compress/">Compress</a></strong> if the file is too big for email. <em>Low</em> rewrites the structure, <em>Medium</em> re-encodes embedded JPEGs, and <em>High</em> also scales them down, so photos may look softer. Text stays the same.</p>
<p><strong>4. <a href="https://findutils.com/pdf/pdf-metadata-remover/">Remove metadata</a></strong> as the very last step. A PDF can carry the author's account name, the program that made it and timestamps. The tool shows the Info fields and XMP data and gives you a copy without the ones you tick. Two honest caveats from the tool's own docs: it is a metadata remover, not a forensic scrub (annotation author names and EXIF inside embedded photos stay), and if you open and re-save the cleaned file in another program, that program writes its own Producer and dates back in. So clean last, then send.</p>
<p>If the file must be locked, <a href="https://findutils.com/pdf/pdf-protect/">PDF Protect</a> adds a password with AES-256 encryption.</p>
<h2>Images: convert, resize, then compress</h2>
<p><strong>1. <a href="https://findutils.com/convert/heic-to-jpg/">HEIC to JPG</a></strong>: iPhone photos often show up as blank thumbnails on Windows or get rejected by upload forms. This tool decodes HEIC with libheif compiled to WebAssembly, right in the tab, and outputs JPG, PNG or WebP. Drop a batch and download a zip.</p>
<p><strong>2. <a href="https://findutils.com/convert/image-resizer/">Image Resizer</a></strong>: resize <em>before</em> you compress. A 4000×3000 photo shown at 800×600 wastes bytes no matter how well you compress it. You can resize by percentage or exact pixels, or use social presets (X, LinkedIn, Instagram, YouTube and more).</p>
<p><strong>3. <a href="https://findutils.com/convert/image-compressor/">Image Compressor</a></strong>: takes JPEG, PNG, WebP and AVIF in, and outputs MozJPEG, WebP, AVIF, or lossless PNG optimized with OxiPNG. One detail people miss: JPEG, WebP and AVIF are lossy at <em>every</em> quality setting, including 100%. If pixels must not change, choose PNG.</p>
<p><strong>Before you publish a photo:</strong> open it in <a href="https://findutils.com/media/media-info-viewer/">Media Info Viewer</a> to see what EXIF data it carries (camera, timestamps, sometimes location).</p>
<h2>When you need it in a script instead</h2>
<p>Browser pages are right for one-off jobs, and anything that needs a local file, the camera or a canvas only works there. For repeatable transforms, FindUtils also has a REST API (387 tools) and an MCP server (388 tools) for AI clients. The catalog is described in an <a href="https://findutils.com/api/openapi.json">OpenAPI 3.1 spec</a> and indexed in <a href="https://findutils.com/llms.txt">llms.txt</a>. Every page also has a Markdown twin: add <code>markdown.md</code> to the path.</p>
<p>To set expectations: running a tool through the API or MCP needs an API key. Keys aren't for sale yet, and early access goes through the contact page. The catalog and schemas are open to read, and the browser tools need no key.</p>
<h2>The short version</h2>
<ul>
<li><strong>Test the "no upload" claim</strong> with the Network tab instead of trusting a badge.</li>
<li><strong>JSON:</strong> format → diff → generate types. Don't expect CSV to keep your types.</li>
<li><strong>PDF:</strong> merge → number → compress → strip metadata <em>last</em>.</li>
<li><strong>Images:</strong> convert → resize → compress, and check EXIF before you publish.</li>
</ul>
<p>Everything above is free at <a href="https://findutils.com">findutils.com</a>. If a tool is missing from your workflow, there's a <a href="https://findutils.com/request-tool/">request page</a>. I read those.</p>
<hr />
<p><em>Olgun Özoktaş builds and reviews the tools on FindUtils.</em></p>
]]></content:encoded></item></channel></rss>