<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ashish Kumar</title>
    <description>The latest articles on DEV Community by Ashish Kumar (@helloashish99).</description>
    <link>https://dev.to/helloashish99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3861766%2F0b3f531d-15d0-4bfa-975a-ce54df37aac8.png</url>
      <title>DEV Community: Ashish Kumar</title>
      <link>https://dev.to/helloashish99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/helloashish99"/>
    <language>en</language>
    <item>
      <title>OPFS: The Browser's Built-in Filesystem Explained</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Wed, 29 Apr 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/opfs-the-browsers-built-in-filesystem-explained-o5i</link>
      <guid>https://dev.to/helloashish99/opfs-the-browsers-built-in-filesystem-explained-o5i</guid>
      <description>&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; caps at 5MB. IndexedDB writes a 50MB buffer in ~850ms — slow enough to feel it. The Cache API is effectively read-only. The File System Access API requires user permission prompts every session. None of these were designed for what serious client-side applications actually need: &lt;strong&gt;large, fast, random-access binary storage&lt;/strong&gt; that persists across sessions without permission prompts.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Origin Private File System (OPFS)&lt;/strong&gt; fills that gap. It is a real, sandboxed filesystem per origin, invisible to the OS file manager, persistent across sessions, and accessible with a synchronous API from within Workers that makes large sequential I/O genuinely fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; The OPFS API in detail, the synchronous access handle (and why it only works in Workers), streaming network responses directly to OPFS, the performance difference vs IndexedDB, and how SQLite runs in the browser using OPFS as its backing store.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc5447glgu010kpczzajw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc5447glgu010kpczzajw.png" alt="Architecture diagram of the Origin Private File System: sandboxed origin storage, access modes, and worker versus main-thread APIs." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The storage landscape problem
&lt;/h2&gt;

&lt;p&gt;Before OPFS existed, the browser gave you four options for persistent storage, and each one was the wrong tool for at least one important job:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/strong&gt; is synchronous (which feels nice), but the 5–10MB limit is a hard wall. It is also string-only, so storing binary data means base64 encoding  which inflates size by ~33% and makes a 5MB limit feel like 3.7MB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IndexedDB&lt;/strong&gt; can handle gigabytes in theory, is asynchronous, and supports structured objects and binary blobs. But the API is callback-hell wrapped in a transaction model that wasn't designed for ergonomics. More practically: writing large sequential blobs to IndexedDB is slow. The implementation serializes data through the structured clone algorithm on every write, and for large files you feel that cost. I measured ~400ms to write a 50MB buffer in Chrome on M2  fine once, unbearable repeatedly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache API&lt;/strong&gt; (Service Worker caches) is designed for HTTP responses and works well for caching network resources. But it is fundamentally read-after-write: you cannot partially update a cached entry or seek to an offset. Building a writable file system on top of it is like building a database on top of a log file  possible in theory, miserable in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File System Access API&lt;/strong&gt; lets you open actual OS files with a picker. That's useful for "open a video from your desktop" flows, but it's not sandboxed  the user sees the file in their Downloads folder, file paths can leak, and the permission model requires a user gesture every session. It's the wrong tool for internal app storage.&lt;/p&gt;

&lt;p&gt;OPFS is the missing piece: a &lt;strong&gt;sandboxed filesystem per origin&lt;/strong&gt;, invisible to the OS file manager, persistent across sessions, and  critically  accessible with a synchronous API from within Workers.&lt;/p&gt;




&lt;h2&gt;
  
  
  What OPFS actually is
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Origin Private File System&lt;/strong&gt; is a real filesystem exposed to web pages through the &lt;code&gt;StorageManager&lt;/code&gt; API. Every origin gets its own isolated root directory. Files you create there are not visible in the OS Finder/Explorer. Other origins cannot access them. Clearing site data removes them.&lt;/p&gt;

&lt;p&gt;You access the root with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns a &lt;code&gt;FileSystemDirectoryHandle&lt;/code&gt;. From there you navigate a real directory tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Create or open a subdirectory&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectoryHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;datasets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Create or open a file&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cacheDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;analytics-v3.bin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To write to that file, you create a &lt;strong&gt;writable stream&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWritable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;myArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To read it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// returns a File object&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// or: const stream = file.stream();&lt;/span&gt;
&lt;span class="c1"&gt;// or: const text = await file.text();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;File&lt;/code&gt; object you get from &lt;code&gt;getFile()&lt;/code&gt; is the same &lt;code&gt;File&lt;/code&gt; type you'd get from an &lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;. You can pass it directly to &lt;code&gt;new Response(file)&lt;/code&gt;, pipe its &lt;code&gt;.stream()&lt;/code&gt; into a &lt;code&gt;TransformStream&lt;/code&gt;, or just read it as an &lt;code&gt;ArrayBuffer&lt;/code&gt;. This composability is one of the things I genuinely like about the API.&lt;/p&gt;




&lt;h2&gt;
  
  
  The synchronous access handle  why it matters
&lt;/h2&gt;

&lt;p&gt;Here is the part that makes OPFS genuinely different from everything else: inside a &lt;strong&gt;Web Worker&lt;/strong&gt;, you can open a file in &lt;strong&gt;synchronous mode&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Inside a Worker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dataset.bin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// This is a synchronous handle  only available in Workers&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;syncHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSyncAccessHandle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Synchronous read&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytesRead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Synchronous write&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// You must flush and close explicitly&lt;/span&gt;
&lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;createSyncAccessHandle()&lt;/code&gt; method is only available in dedicated Workers  it deliberately cannot be called on the main thread, because synchronous I/O on the main thread would block rendering. But in a Worker, synchronous access is exactly what you want: no Promise overhead, no microtask queue, just a tight read/write loop that runs at native speed.&lt;/p&gt;

&lt;p&gt;The performance difference is meaningful. In my testing, writing a 100MB &lt;code&gt;ArrayBuffer&lt;/code&gt; with &lt;code&gt;createSyncAccessHandle&lt;/code&gt; took ~90ms. The equivalent IndexedDB write took ~850ms. The gap comes from two places: OPFS bypasses the structured clone algorithm for raw binary data, and the synchronous handle avoids the async event loop overhead that accumulates across thousands of small writes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Worker + OPFS patterns
&lt;/h2&gt;

&lt;p&gt;The right architecture is: &lt;strong&gt;all OPFS work in a Worker, communicate with the main thread via messages&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the pattern I ended up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// opfs-worker.js&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WRITE_FILE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;syncHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSyncAccessHandle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;syncHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WRITE_DONE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;READ_FILE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Transfer the buffer (zero-copy)&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;READ_DONE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main thread&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/opfs-worker.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WRITE_FILE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;analytics-v3.bin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;myArrayBuffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;myArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// transfer ownership  zero copy&lt;/span&gt;

&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;READ_DONE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;processData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key here is &lt;strong&gt;Transferable objects&lt;/strong&gt;. When you pass &lt;code&gt;[myArrayBuffer]&lt;/code&gt; as the second argument to &lt;code&gt;postMessage&lt;/code&gt;, ownership transfers to the Worker  no copy is made. For a 150MB buffer, this matters. Without transfer, the browser would serialize and copy the data twice (once into the Worker's memory, once back). With transfer, it's a pointer swap  essentially free.&lt;/p&gt;




&lt;h2&gt;
  
  
  Streaming writes from the network
&lt;/h2&gt;

&lt;p&gt;One of the most useful patterns is streaming a network response directly to OPFS, without ever materializing the full response in memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stream a large file from the network directly to OPFS&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;streamToOPFS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fileHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWritable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ReadableStream not supported&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Pipe the response body directly to OPFS&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// writable is automatically closed when pipeTo() resolves&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createWritable()&lt;/code&gt; returns a &lt;code&gt;FileSystemWritableFileStream&lt;/code&gt;, which implements the WHATWG &lt;code&gt;WritableStream&lt;/code&gt; interface. That means you can &lt;code&gt;pipeTo()&lt;/code&gt; any &lt;code&gt;ReadableStream&lt;/code&gt; directly into it  including &lt;code&gt;response.body&lt;/code&gt;. The data flows through without a single full-buffer copy in JavaScript land. For large WASM binaries or video files, this is the right way to download and cache them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Real use case: caching a 200MB WASM binary
&lt;/h2&gt;

&lt;p&gt;This was essentially what we were doing. We had a data-processing WASM module that was 200MB and updated infrequently. On first load, we streamed it from the CDN into OPFS. On subsequent loads, we checked if the cached version was current (compared an ETag stored in &lt;code&gt;localStorage&lt;/code&gt;), and if so, read it straight from OPFS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getWasmModule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectory&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDirectoryHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wasm-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;storedEtag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wasm-etag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storedEtag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cacheDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module.wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;WebAssembly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compileStreaming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// File doesn't exist yet, fall through to fetch&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Fetch and cache&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stream1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stream2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tee&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cacheDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module.wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWritable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Write to OPFS and compile simultaneously&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wasmModule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;WebAssembly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compileStreaming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wasm-etag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;etag&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;wasmModule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ReadableStream.tee()&lt;/code&gt; lets us split one response body into two  one piped into WASM compilation, one saved to OPFS. The module compiles and caches in a single network round trip.&lt;/p&gt;




&lt;h2&gt;
  
  
  OPFS + SQLite: the surprising use case
&lt;/h2&gt;

&lt;p&gt;One of the most interesting production uses of OPFS is as a &lt;strong&gt;backing store for SQLite in the browser&lt;/strong&gt;. The &lt;code&gt;sqlite-wasm&lt;/code&gt; project (the official SQLite WASM build) uses the synchronous OPFS access handle to implement POSIX-style file I/O:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sqlite3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sqlite3InitModule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;print&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Use OPFS-backed database (runs in a Worker)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oo1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OpfsDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/myapp.sqlite3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, data TEXT)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO events(data) VALUES('hello')&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;returnValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resultRows&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a full SQLite database, persisted to OPFS, with ACID transactions, SQL queries, and all the features you'd expect  running entirely in the browser. The sync handle's random-access read/write is what makes this possible: SQLite needs to be able to seek to arbitrary byte offsets and do partial writes, which is exactly what &lt;code&gt;syncHandle.read(buffer, { at: offset })&lt;/code&gt; provides.&lt;/p&gt;




&lt;h2&gt;
  
  
  Storage comparison table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;localStorage&lt;/th&gt;
&lt;th&gt;IndexedDB&lt;/th&gt;
&lt;th&gt;Cache API&lt;/th&gt;
&lt;th&gt;OPFS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Size limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5MB&lt;/td&gt;
&lt;td&gt;Hundreds of MB / GB&lt;/td&gt;
&lt;td&gt;Hundreds of MB / GB&lt;/td&gt;
&lt;td&gt;Hundreds of MB / GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API style&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Synchronous&lt;/td&gt;
&lt;td&gt;Async (promises)&lt;/td&gt;
&lt;td&gt;Async (promises)&lt;/td&gt;
&lt;td&gt;Async main / Sync in Worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Binary support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (base64 only)&lt;/td&gt;
&lt;td&gt;Yes (Blob/ArrayBuffer)&lt;/td&gt;
&lt;td&gt;Yes (Response body)&lt;/td&gt;
&lt;td&gt;Yes (native)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Random access&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (seek via cursors)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;{ at: offset }&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write speed (100MB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;~850ms&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;~90ms (sync handle)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Streaming writes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Via fetch&lt;/td&gt;
&lt;td&gt;Yes (WritableStream)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Worker access&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (sync handle in Worker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Small key-value config&lt;/td&gt;
&lt;td&gt;Structured app data&lt;/td&gt;
&lt;td&gt;HTTP response caching&lt;/td&gt;
&lt;td&gt;Large blobs, binary files, SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Quota management
&lt;/h2&gt;

&lt;p&gt;OPFS storage counts toward the &lt;strong&gt;origin's storage quota&lt;/strong&gt;, shared with IndexedDB and the Cache API. The browser manages this as a pool. You can query it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Used: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Available: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quota&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Breakdown by storage type&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usageDetails&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// { indexedDB: 1234567, caches: 0, fileSystem: 208000000 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Chrome, the quota is typically around 60% of available disk space. On mobile, it can be much smaller, and the browser can evict storage under pressure unless you've called &lt;code&gt;navigator.storage.persist()&lt;/code&gt; and the user granted permission.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Request persistent storage (shows a permission prompt or is auto-granted based on engagement)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPersisted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPersisted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Storage may be evicted under disk pressure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a production app, always check &lt;code&gt;estimate()&lt;/code&gt; before writing large blobs and handle the case where quota is insufficient gracefully.&lt;/p&gt;




&lt;h2&gt;
  
  
  Browser support
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;OPFS Available&lt;/th&gt;
&lt;th&gt;Sync Access Handle&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome 102+&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (Chrome 108+)&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox 111+&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari 15.2+&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (Safari 16+)&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge 102+&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Chromium-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS Safari 15.2+&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes (16+)&lt;/td&gt;
&lt;td&gt;Storage limits tighter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome Android&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Same as desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The main gap to watch is &lt;strong&gt;iOS Safari storage limits&lt;/strong&gt;  Apple's browsers on iOS are more aggressive about quota eviction and the persist() permission is harder to get. If you're targeting iOS for large offline datasets, test on actual iOS hardware and check &lt;code&gt;estimate()&lt;/code&gt; before assuming you have the space.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;OPFS is not glamorous. It doesn't show up in "top 10 web APIs" listicles. But if you've ever hit the limits of IndexedDB for large binary data, or tried to build an offline-capable app with complex storage needs, it's the API that should have been there all along.&lt;/p&gt;

&lt;p&gt;The mental model is simple: it's a real filesystem, sandboxed per origin, with a synchronous API available in Workers that makes large sequential I/O genuinely fast. Pair it with Transferable objects for zero-copy messaging, stream network responses directly into it, and use &lt;code&gt;sqlite-wasm&lt;/code&gt; if you need a full relational layer.&lt;/p&gt;

&lt;p&gt;The storage landscape went: &lt;code&gt;localStorage&lt;/code&gt; → IndexedDB → Cache API → File System Access API → OPFS. Each one fills a different gap. OPFS fills the one that matters most for serious client-side data: fast, large, random-access binary storage that you actually control.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/origin-private-file-system-opfs/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/origin-private-file-system-opfs/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you found this helpful, I've also built some free tools for developers and everyday users. Feel free to try them once:&lt;/p&gt;

&lt;p&gt;JSON Tools: &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt;&lt;br&gt;
Text Tools: &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt;&lt;br&gt;
QR Tools: &lt;a href="https://qr.renderlog.in" rel="noopener noreferrer"&gt;https://qr.renderlog.in&lt;/a&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>opfs</category>
      <category>storage</category>
      <category>webworkers</category>
    </item>
    <item>
      <title>Network Optimization in React SPAs: Prefetching</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Tue, 28 Apr 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/network-optimization-in-react-spas-prefetching-13ak</link>
      <guid>https://dev.to/helloashish99/network-optimization-in-react-spas-prefetching-13ak</guid>
      <description>&lt;p&gt;SPAs bypass the browser's built-in HTTP caching by routing in JavaScript: the page never reloads, so the browser never re-evaluates cache headers on the HTML. Every component instance fetches its own data. Two components on the same page requesting &lt;code&gt;/api/user&lt;/code&gt; fire two separate network requests. Navigating back to a route you visited 30 seconds ago triggers a full refetch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; Each &lt;code&gt;useEffect&lt;/code&gt;-based fetch introduces a render-then-fetch waterfall. Stacked across multiple nested route components, this compounds to seconds of latency on navigations that could be instant with correct caching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; HTTP cache headers and &lt;code&gt;stale-while-revalidate&lt;/code&gt;, how React Query implements client-side SWR, request deduplication, avoiding request waterfalls with route loaders, prefetch-on-hover, and Service Worker caching strategies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7uoo8o8lc68vknngulck.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7uoo8o8lc68vknngulck.png" alt="Diagram contrasting a useEffect fetch waterfall in nested routes with coordinated data loading from route loaders." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The SPA network problem
&lt;/h2&gt;

&lt;p&gt;Server-rendered apps get HTTP caching for free  the browser caches the HTML response, and subsequent navigations can be served from the browser cache. SPAs bypass this by routing in JavaScript: the page never reloads, so the browser never re-evaluates cache headers on the HTML. The app controls its own data fetching, and by default, React components have no shared cache  every component instance fetches its own data.&lt;/p&gt;

&lt;p&gt;The consequences compound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Waterfall fetches on navigation&lt;/strong&gt;  a route component mounts, kicks off a &lt;code&gt;useEffect&lt;/code&gt;, waits for the response, then maybe kicks off more fetches based on the result&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No deduplication&lt;/strong&gt;  two components on the same page both requesting &lt;code&gt;/api/user&lt;/code&gt; will fire two separate network requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache amnesia&lt;/strong&gt;  navigating back to a page you visited 10 seconds ago triggers a full refetch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loading states everywhere&lt;/strong&gt;  because you're always waiting for network, even for data you theoretically already have&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixing this properly requires thinking at multiple levels: HTTP caching, in-memory client-side caching, and request coordination.&lt;/p&gt;




&lt;h2&gt;
  
  
  HTTP cache headers: the foundation you're probably skipping
&lt;/h2&gt;

&lt;p&gt;Before reaching for a library, it's worth understanding what HTTP caching can do for you. Most React apps I've seen send API responses with &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; or no cache headers at all  effectively opting out of one of the most powerful performance mechanisms in the web platform.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Cache-Control&lt;/code&gt; header controls how long a response can be cached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Cache for 60 seconds, no revalidation needed
Cache-Control: max-age=60

# Cache but always revalidate with If-None-Match before using
Cache-Control: no-cache
ETag: "abc123"

# Serve cached version immediately, revalidate in background
Cache-Control: max-age=60, stale-while-revalidate=300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;max-age&lt;/code&gt;&lt;/strong&gt; tells the browser it can use the cached response for N seconds without hitting the network at all. For data that changes infrequently (user settings, feature flags, navigation structure), setting &lt;code&gt;max-age=300&lt;/code&gt; (5 minutes) eliminates unnecessary network round trips on every navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ETag&lt;/code&gt;&lt;/strong&gt; lets the server say "this is version X of this resource." On subsequent requests, the browser sends &lt;code&gt;If-None-Match: "abc123"&lt;/code&gt;. If the resource hasn't changed, the server responds with &lt;code&gt;304 Not Modified&lt;/code&gt; and no body  the browser uses its cached copy. You save bandwidth but still pay the round-trip latency. Useful for data that changes unpredictably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;stale-while-revalidate&lt;/code&gt;&lt;/strong&gt; is the most useful directive for SPA data. It means: serve the cached version immediately (no network wait), but in the background revalidate with the server. If the server has fresh data, update the cache for the next request. The user never sees a loading state  they see the old data instantly, and if anything changed, it updates on the next navigation.&lt;/p&gt;

&lt;p&gt;The actual HTTP &lt;code&gt;stale-while-revalidate&lt;/code&gt; directive requires server-side support. But even if your API doesn't support it, you can implement the same pattern in client-side JavaScript  which is exactly what React Query and SWR do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9snsbfh3o6xzoffa1q76.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9snsbfh3o6xzoffa1q76.png" alt="Pipeline diagram for stale-while-revalidate: serve cached data immediately while a background request refreshes the cache." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Stale-while-revalidate in JavaScript: React Query internals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;React Query&lt;/strong&gt; (now &lt;strong&gt;TanStack Query&lt;/strong&gt;) implements the stale-while-revalidate pattern in the browser's memory. Here's the mental model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the first request for a query key, fetch from the network and cache the result&lt;/li&gt;
&lt;li&gt;On subsequent requests for the same key, return the cached result immediately&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;staleTime&lt;/code&gt; has elapsed, fire a background revalidation request&lt;/li&gt;
&lt;li&gt;When revalidation completes, update the cache and re-render components that are subscribed to this key
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Basic React Query setup&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFetching&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/user/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Data is "fresh" for 60 seconds&lt;/span&gt;
  &lt;span class="na"&gt;gcTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// Keep in memory for 5 minutes after last use&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// isLoading: true only on the very first fetch (no cached data)&lt;/span&gt;
&lt;span class="c1"&gt;// isFetching: true whenever a network request is in flight (including background)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key distinction is &lt;code&gt;isLoading&lt;/code&gt; vs &lt;code&gt;isFetching&lt;/code&gt;. &lt;code&gt;isLoading&lt;/code&gt; is true only when there's no cached data at all  the initial state. &lt;code&gt;isFetching&lt;/code&gt; is true during any network activity, including background revalidations. If you show your loading spinner on &lt;code&gt;isFetching&lt;/code&gt; instead of &lt;code&gt;isLoading&lt;/code&gt;, you'll show a spinner during every background revalidation, which is the bug we started with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG: shows spinner during background refetches&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isFetching&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// RIGHT: only shows spinner when there's genuinely no data to show&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Request deduplication
&lt;/h2&gt;

&lt;p&gt;Request deduplication means that if two components both request the same query key simultaneously, only one network request fires. React Query handles this automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Both of these components can exist on the same page simultaneously.&lt;/span&gt;
&lt;span class="c1"&gt;// React Query fires exactly ONE network request for ['user', 'profile'].&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fetchProfile&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Sidebar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fetchProfile&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, React Query maintains a &lt;strong&gt;query cache&lt;/strong&gt; keyed by the serialized query key. When the second component calls &lt;code&gt;useQuery&lt;/code&gt; with the same key while the first request is still in-flight, it subscribes to the same pending Promise. Both components re-render when the single request resolves.&lt;/p&gt;

&lt;p&gt;Without this, a page with 3 components all fetching the user profile would fire 3 API calls on every mount. With React Query, it's always 1. At scale  across hundreds of components, dozens of users, millions of navigations  this is a meaningful reduction in API server load, not just network performance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Avoiding request waterfalls in React
&lt;/h2&gt;

&lt;p&gt;The most expensive network pattern in React SPAs is the &lt;strong&gt;request waterfall&lt;/strong&gt;: a component mounts, fetches data, receives the response, then fetches more data based on what it received.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WATERFALL: this pattern creates a network chain&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserDashboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPosts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Can't fetch posts until we have the userId  creates a waterfall&lt;/span&gt;
      &lt;span class="nf"&gt;fetchPostsByUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setPosts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;fetchUser&lt;/code&gt; takes 200ms and &lt;code&gt;fetchPostsByUser&lt;/code&gt; takes 150ms, total load time is 350ms. If you parallelize them (when you know the userId ahead of time), it's 200ms. The waterfall cost compounds with every additional sequential fetch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React Router loaders&lt;/strong&gt; solve this by moving data fetching outside the component tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// React Router v6.4+ loaders&lt;/span&gt;

  &lt;span class="c1"&gt;// These run in parallel  no waterfall&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;fetchPostsByUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserDashboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useLoaderData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Data is already here when the component mounts&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loaders run as soon as the route transition begins  before the component mounts, and in parallel with each other if multiple routes load simultaneously. The component never needs a loading state for the initial data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prefetching strategies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prefetching&lt;/strong&gt; is fetching data before the user navigates to it  so when they do navigate, the data is already in cache.&lt;/p&gt;

&lt;p&gt;React Query exposes &lt;code&gt;queryClient.prefetchQuery()&lt;/code&gt; for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Prefetch on hover  user is probably about to click&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NavLink&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetchQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick with hover prefetching is that the human reaction time from hover to click is typically &lt;strong&gt;100–200ms&lt;/strong&gt;. A fast API response takes 50–150ms. If you kick off the prefetch on hover, the data is often already in cache by the time the navigation happens.&lt;/p&gt;

&lt;p&gt;For less predictable navigation, prefetch on &lt;code&gt;requestIdleCallback&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Prefetch the most likely next routes when the browser is idle&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prefetchLikelyRoutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;LIKELY_NEXT_ROUTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryFn&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prefetchQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;staleTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Resource hints: preconnect, prefetch, preload
&lt;/h2&gt;

&lt;p&gt;HTML resource hints give the browser signals about what to load before the page needs it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Preconnect: establish TCP/TLS with the API domain early --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://api.myapp.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Prefetch: download a resource in the background for future navigations --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/dashboard"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Preload: download a resource needed for THIS page, high priority --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/fonts/inter.woff2"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"font"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hint&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preconnect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Known API domains you'll fetch from on page load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dns-prefetch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Domains you might fetch from (lower cost than preconnect)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Resources needed for current page (LCP image, critical font, main JS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefetch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Idle&lt;/td&gt;
&lt;td&gt;Resources needed for likely next page (low-priority, uses idle time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;modulepreload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;JavaScript modules needed for next route&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;preconnect&lt;/code&gt; is the easiest win. Your API domain requires DNS lookup + TCP handshake + TLS handshake before the first byte  that's typically 100–300ms. With &lt;code&gt;&amp;lt;link rel="preconnect"&amp;gt;&lt;/code&gt;, that work happens while the HTML is being parsed, not when your JavaScript first calls &lt;code&gt;fetch()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Priority Hints
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fetchpriority&lt;/code&gt; tells the browser how to prioritize resource downloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- LCP image: fetch immediately, high priority --&amp;gt;&lt;/span&gt;
![](https://renderlog.in/hero.jpg)

&lt;span class="c"&gt;&amp;lt;!-- Below-fold image: deprioritize --&amp;gt;&lt;/span&gt;
![](https://renderlog.in/footer-decoration.jpg)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// High-priority fetch for critical data&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;criticalData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/critical-config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Low-priority fetch for analytics or non-blocking data&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/track-view&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser has a limited number of simultaneous connections per domain (typically 6 for HTTP/1.1, effectively unlimited for HTTP/2). Without priority hints, it fetches in order of discovery  which might mean your LCP image is queued behind a dozen low-priority requests. &lt;code&gt;fetchpriority="high"&lt;/code&gt; ensures the browser front-runs it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Service Workers for network interception
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Service Worker&lt;/strong&gt; sits between your app and the network and can implement sophisticated caching strategies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// service-worker.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api-cache-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CACHEABLE_APIS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/user/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/feature-flags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHEABLE_APIS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;staleWhileRevalidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;staleWhileRevalidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Always kick off a revalidation in the background&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;networkFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Return cached immediately if available, else wait for network&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;networkFetch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Service Worker pattern is more powerful than React Query alone for one reason: it works &lt;strong&gt;across page loads&lt;/strong&gt;. React Query's cache lives in memory  it's cleared when the user closes the tab. A Service Worker cache persists across navigations and can serve data offline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Connection-aware fetching
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;Network Information API&lt;/strong&gt; gives you the user's connection type, which lets you adapt what you fetch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getImageQuality&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// API not supported, assume fast&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saveData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// User explicitly wants to save data&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2g&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3g&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Adaptive image loading&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getImageQuality&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/api/image/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?quality=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API is available in Chrome and Android browsers but not Safari. Always check for its existence before using it, and never use it as the only signal  fall back to a sensible default.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the network tab tells you
&lt;/h2&gt;

&lt;p&gt;When diagnosing SPA network performance, I filter the Chrome DevTools Network tab to &lt;strong&gt;XHR/Fetch&lt;/strong&gt; and look for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate requests&lt;/strong&gt;  same URL called multiple times in rapid succession (deduplication needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sequential requests&lt;/strong&gt;  requests starting only after previous ones complete (waterfall pattern)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache misses on repeat visits&lt;/strong&gt;  &lt;code&gt;Status: 200&lt;/code&gt; on requests you'd expect to be &lt;code&gt;304 Not Modified&lt;/code&gt; (cache headers misconfigured)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unthrottled polling&lt;/strong&gt;  the same request firing every few seconds even when nothing changed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stacking spinners&lt;/strong&gt;  multiple loading states visible at once (data needs to be fetched in parallel, not sequentially)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule of thumb I use: &lt;strong&gt;if you see the same URL more than once in a 10-second window without explicit user action, you have a caching problem.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The layered approach to SPA network performance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Wins&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Cache-Control&lt;/code&gt;, &lt;code&gt;ETag&lt;/code&gt;, &lt;code&gt;stale-while-revalidate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Eliminates round trips for stable data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource hints&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;preconnect&lt;/code&gt;, &lt;code&gt;preload&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reduces connection and load latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client cache&lt;/td&gt;
&lt;td&gt;React Query / SWR&lt;/td&gt;
&lt;td&gt;Deduplication, stale-while-revalidate, garbage collection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;Route loaders, parallel fetches&lt;/td&gt;
&lt;td&gt;Eliminates sequential waterfall fetches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefetching&lt;/td&gt;
&lt;td&gt;On hover, on idle&lt;/td&gt;
&lt;td&gt;Populates cache before navigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service Worker&lt;/td&gt;
&lt;td&gt;Cache-first / network-first strategies&lt;/td&gt;
&lt;td&gt;Persistence across page loads, offline support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each layer is independent  you can add them incrementally. Start with React Query and &lt;code&gt;staleTime: 60000&lt;/code&gt; on your most-fetched queries, add &lt;code&gt;&amp;lt;link rel="preconnect"&amp;gt;&lt;/code&gt; for your API domain, and measure. The 800ms spinner on every navigation usually disappears after just the first two steps.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/network-optimization-spa-react/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/network-optimization-spa-react/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you found this helpful, I've also built some free tools for developers and everyday users. Feel free to try them once:&lt;/p&gt;

&lt;p&gt;JSON Tools: &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt;&lt;br&gt;
Text Tools: &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt;&lt;br&gt;
QR Tools: &lt;a href="https://qr.renderlog.in" rel="noopener noreferrer"&gt;https://qr.renderlog.in&lt;/a&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>network</category>
      <category>react</category>
      <category>caching</category>
    </item>
    <item>
      <title>DOM Performance on Mobile: Lab vs Real Device Reality</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Mon, 27 Apr 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/dom-performance-on-mobile-lab-vs-real-device-reality-5gab</link>
      <guid>https://dev.to/helloashish99/dom-performance-on-mobile-lab-vs-real-device-reality-5gab</guid>
      <description>&lt;p&gt;Style recalculation on a page with 10,000 DOM nodes takes ~180ms on a budget Android — 10x the entire 16.6ms frame budget. The exact same scroll that is imperceptible on a developer MacBook drops 90% of frames on a Redmi Note 9 or Samsung A-series device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why desktop profiling is misleading:&lt;/strong&gt; The V8 engine on a budget Android runs at 5–10x lower throughput than on a developer laptop. Chrome's 6x CPU throttle preset is still optimistic for real low-end hardware. The only reliable signal is remote debugging on physical budget devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; DOM size and style recalculation benchmarks on real hardware, &lt;code&gt;content-visibility: auto&lt;/code&gt;, CSS &lt;code&gt;contain&lt;/code&gt;, passive touch event listeners, IntersectionObserver vs scroll events, and the full mobile debugging workflow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp0v4sql5269s3n0nfsvp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp0v4sql5269s3n0nfsvp.png" alt="Diagram of the performance gap between developer laptops and budget mobile devices for DOM, JS, and rendering work." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The mobile hardware reality
&lt;/h2&gt;

&lt;p&gt;Before getting into fixes, it's worth internalizing exactly why budget phones are so much slower  not as an abstraction, but as a design constraint.&lt;/p&gt;

&lt;p&gt;A typical mid-range Android in 2026 (Redmi Note series, Samsung A-series, Motorola G-series) has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2–4 CPU cores&lt;/strong&gt; running at &lt;strong&gt;1.5–2.0 GHz&lt;/strong&gt;, compared to your MacBook's 8–10 cores at 3–4 GHz&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3–4GB RAM&lt;/strong&gt; (often shared with the OS, background apps, and the GPU)), with aggressive memory pressure killing background tabs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No separate GPU memory&lt;/strong&gt;  the integrated GPU (shares RAM bandwidth with the CPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thermal throttling&lt;/strong&gt;: after 2–3 minutes of heavy load, the chip throttles to 60–70% of its peak frequency to avoid overheating&lt;/li&gt;
&lt;li&gt;A **V8 JavaScript engine: that's the same version as desktop but running on a fraction of the hardware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The V8 engine on a budget Android is typically &lt;strong&gt;5–10x slower&lt;/strong&gt; at JS execution than on a developer laptop. Not because the software is different  it's the same engine  but because the hardware is so much weaker. The JIT compiler has less time budget, inline caches fill differently, and garbage collection pauses are longer in relative terms.&lt;/p&gt;

&lt;p&gt;The consequence: &lt;strong&gt;performance profiling on your development machine is actively misleading&lt;/strong&gt;. You will mark tasks as "fast" that are catastrophically slow on real user hardware.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chrome DevTools CPU throttling isn't enough
&lt;/h2&gt;

&lt;p&gt;Chrome's "6x slowdown" CPU throttle in DevTools is a software throttle: it introduces artificial delays in the main thread scheduling. It doesn't simulate reduced memory bandwidth, it doesn't simulate thermal throttling, and it doesn't simulate the actual V8 JIT behavior on ARM hardware with constrained memory.&lt;/p&gt;

&lt;p&gt;It's better than nothing. But I've found that even the 6x throttle is &lt;strong&gt;optimistic&lt;/strong&gt; compared to a Redmi Note 9 or a Realme 8. Real users are often on hardware that would show up as 8–12x slower in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The only reliable signal is remote debugging on real hardware.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# On Android: enable Developer Options, enable USB Debugging
# On your laptop:
chrome://inspect/#devices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect your phone, open the app in Chrome on the phone, and it shows up in &lt;code&gt;chrome://inspect&lt;/code&gt;. You get the full DevTools Performance panel  Timeline, flame charts, frame rate  running against the real hardware. I've started keeping a Redmi Note 9 on my desk specifically for this.&lt;/p&gt;




&lt;h2&gt;
  
  
  DOM size and style recalculation cost
&lt;/h2&gt;

&lt;p&gt;Here's the fundamental problem with large DOM trees that most developers don't feel until they test on mobile: &lt;strong&gt;CSS selector matching is O(n) per element per style recalculation&lt;/strong&gt;, and it scales badly.&lt;/p&gt;

&lt;p&gt;When you invalidate styles on a node (by adding a class, changing a property, or causing a reflow), the browser must re-run selector matching for potentially large subtrees. Selectors are matched &lt;strong&gt;right-to-left&lt;/strong&gt;  the browser finds all elements that match the rightmost part of the selector, then walks up the tree checking each parent. A selector like &lt;code&gt;.sidebar .nav-item a:hover&lt;/code&gt; can be surprisingly expensive if &lt;code&gt;.sidebar&lt;/code&gt; contains hundreds of elements.&lt;/p&gt;

&lt;p&gt;Chrome's DevTools calls this &lt;strong&gt;"Recalculate Style"&lt;/strong&gt; in the Performance panel. When you see it taking 50ms+ on a frame, you have a DOM size / selector complexity problem.&lt;/p&gt;

&lt;p&gt;Some real numbers from my testing (Redmi Note 9, Chrome 122):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DOM node count&lt;/th&gt;
&lt;th&gt;Recalc Style time (class toggle)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;500 nodes&lt;/td&gt;
&lt;td&gt;~4ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,500 nodes&lt;/td&gt;
&lt;td&gt;~14ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3,000 nodes&lt;/td&gt;
&lt;td&gt;~35ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6,000 nodes&lt;/td&gt;
&lt;td&gt;~90ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000 nodes&lt;/td&gt;
&lt;td&gt;~180ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 10,000 nodes, a single class toggle on a parent element costs 180ms  10x the entire 16.6ms frame budget. This is directly why our user's phone was "unusable."&lt;/p&gt;

&lt;p&gt;The fix is straightforward in principle: &lt;strong&gt;fewer nodes&lt;/strong&gt;. In practice, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Virtualizing long lists (only render what's in the viewport)&lt;/li&gt;
&lt;li&gt;Not using deep nesting for layout purposes that could be flattened&lt;/li&gt;
&lt;li&gt;Avoiding &lt;code&gt;display: none&lt;/code&gt; containers that still exist in the DOM  they still participate in style matching&lt;/li&gt;
&lt;li&gt;Auditing third-party components that inject dozens of wrapper divs for no structural reason&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Mounting discipline: deferring below-fold components
&lt;/h2&gt;

&lt;p&gt;One of the highest-leverage changes on the scroll path is &lt;strong&gt;deferring the mounting of below-fold components&lt;/strong&gt;. If a component isn't visible when the page loads, you don't need it in the DOM immediately.&lt;/p&gt;

&lt;p&gt;The naive approach is &lt;code&gt;requestIdleCallback&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't mount everything at once&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BelowFoldSection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMounted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setMounted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;cancelIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="na"&gt;minHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;400px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The better approach for scroll-triggered content is &lt;code&gt;IntersectionObserver&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LazySection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;placeholder&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setVisible&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rootMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;200px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// Start loading 200px before it enters viewport&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 200px &lt;code&gt;rootMargin&lt;/code&gt; gives you a buffer: components start mounting before the user scrolls to them, so there's no visible loading flash. Measure the actual mount cost of each section in the Performance panel before deciding which ones to defer.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;content-visibility: auto&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;CSS &lt;code&gt;content-visibility: auto&lt;/code&gt; is a one-liner that tells the browser to &lt;strong&gt;skip layout and paint for off-screen content entirely&lt;/strong&gt;. It's essentially the CSS-native version of the above IntersectionObserver pattern, but implemented at the rendering engine level rather than in JavaScript.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.article-section&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;content-visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;contain-intrinsic-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;500px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* Hint for placeholder height */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an element with &lt;code&gt;content-visibility: auto&lt;/code&gt; is off-screen, the browser skips its layout and paint entirely. It doesn't remove it from the DOM (the content is still there and accessible), but the browser treats it as if it has &lt;code&gt;visibility: hidden&lt;/code&gt; for rendering purposes. When it scrolls into view: the browser renders it on demand.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;contain-intrinsic-size&lt;/code&gt; property gives the browser a fallback size to use for layout calculations while the content isn't rendered. Without it, the element collapses to 0 height, which makes scrollbar sizing wrong and can cause layout jumps as content renders.&lt;/p&gt;

&lt;p&gt;The limitation: if your sections have genuinely variable heights and you don't know them ahead of time, &lt;code&gt;contain-intrinsic-size&lt;/code&gt; requires estimation. Google shipped a &lt;code&gt;auto&lt;/code&gt; keyword (&lt;code&gt;contain-intrinsic-size: auto 500px&lt;/code&gt;) that remembers the last rendered size, which handles this in most cases.&lt;/p&gt;

&lt;p&gt;The performance improvement is real. The Chrome team's case study showed 7x rendering improvement on a long article page. On mobile hardware with 3,000+ nodes in a long-form page, this is one of the highest-ROI CSS changes you can make.&lt;/p&gt;




&lt;h2&gt;
  
  
  CSS &lt;code&gt;contain&lt;/code&gt; property
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;content-visibility&lt;/code&gt; uses &lt;strong&gt;CSS containment&lt;/strong&gt; under the hood. Understanding containment directly gives you finer-grained control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;layout&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="n"&gt;paint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;contain&lt;/code&gt; property tells the browser that changes inside this element cannot affect anything outside it. There are four containment types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Containment&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;layout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Layout inside this element cannot affect outside layout. Enables layout isolation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;style&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CSS counters and quotes are scoped to this element.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Content outside this element's bounds is not painted. Enables paint isolation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The element's size is independent of its children. Required for intrinsic-size guarantees.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical value of &lt;code&gt;contain: layout paint&lt;/code&gt; on card components is that a re-layout inside one card doesn't trigger a full page reflow. On mobile, where reflows are expensive, this is meaningful.&lt;/p&gt;

&lt;p&gt;`contain: strict: is shorthand for all four types  use it for truly isolated widgets like ads, embeds, or sidebar components that have fixed sizes and no cross-document layout relationships.&lt;/p&gt;




&lt;h2&gt;
  
  
  Touch scroll performance
&lt;/h2&gt;

&lt;p&gt;iOS has a history with scrolling that explains several CSS properties you'll see in older codebases:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;css&lt;br&gt;
/* This was required in iOS &amp;lt;13 for momentum scrolling */&lt;br&gt;
.scroll-container {&lt;br&gt;
  overflow: scroll;&lt;br&gt;
  -webkit-overflow-scrolling: touch; /* Deprecated but still seen */&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-webkit-overflow-scrolling: touch&lt;/code&gt; was originally required on iOS to get the native momentum scroll behavior inside overflow containers. It's been deprecated since iOS 13 (the browser now handles it automatically), but it still appears in codebases. You can safely remove it today.&lt;/p&gt;

&lt;p&gt;What still matters is &lt;strong&gt;passive event listeners&lt;/strong&gt; for touch handlers:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`js&lt;br&gt;
// BAD: blocks scroll until handler returns&lt;br&gt;
element.addEventListener('touchstart', handler);&lt;/p&gt;

&lt;p&gt;// GOOD: tells browser the handler won't call preventDefault()&lt;br&gt;
element.addEventListener('touchstart', handler, { passive: true });&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;When a touch event listener is registered without &lt;code&gt;{ passive: true }&lt;/code&gt;, the browser must wait for your handler to return before it can scroll  because your handler might call &lt;code&gt;e.preventDefault()&lt;/code&gt; to cancel the scroll. This waiting introduces &lt;strong&gt;scroll jank&lt;/strong&gt;. With &lt;code&gt;{ passive: true }&lt;/code&gt;, the browser knows it can start scrolling immediately without waiting.&lt;/p&gt;

&lt;p&gt;Chrome DevTools will warn you about non-passive scroll listeners in the console:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
[Violation] Added non-passive event listener to a scroll-blocking event&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is one of the easiest performance wins on mobile: just add &lt;code&gt;{ passive: true }&lt;/code&gt; to every &lt;code&gt;touchstart&lt;/code&gt;, &lt;code&gt;touchmove&lt;/code&gt;, and &lt;code&gt;wheel&lt;/code&gt; listener that doesn't need to block the scroll.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scroll jank: the full picture
&lt;/h2&gt;

&lt;p&gt;Scroll jank sources ranked by how often I see them in real apps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Non-passive listeners&lt;/td&gt;
&lt;td&gt;Browser waits for JS before scrolling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ passive: true }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layout reads during scroll&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getBoundingClientRect()&lt;/code&gt; / &lt;code&gt;offsetTop&lt;/code&gt; in scroll handler&lt;/td&gt;
&lt;td&gt;Batch reads, use IntersectionObserver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy onscroll handlers&lt;/td&gt;
&lt;td&gt;DOM manipulation, state updates every pixel&lt;/td&gt;
&lt;td&gt;Throttle with &lt;code&gt;requestAnimationFrame&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large DOM&lt;/td&gt;
&lt;td&gt;Style recalc dominates frame time&lt;/td&gt;
&lt;td&gt;Virtualize lists, reduce node count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compositor-layer overflow&lt;/td&gt;
&lt;td&gt;Too many &lt;code&gt;will-change&lt;/code&gt; / &lt;code&gt;transform: translateZ(0)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Audit GPU memory usage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;requestAnimationFrame&lt;/code&gt; pattern for scroll handlers is worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`js&lt;br&gt;
let lastScroll = 0;&lt;br&gt;
let ticking = false;&lt;/p&gt;

&lt;p&gt;window.addEventListener('scroll', () =&amp;gt; {&lt;br&gt;
  lastScroll = window.scrollY;&lt;/p&gt;

&lt;p&gt;if (!ticking) {&lt;br&gt;
    requestAnimationFrame(() =&amp;gt; {&lt;br&gt;
      updateUI(lastScroll);&lt;br&gt;
      ticking = false;&lt;br&gt;
    });&lt;br&gt;
    ticking = true;&lt;br&gt;
  }&lt;br&gt;
}, { passive: true });&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This ensures your scroll handler runs &lt;strong&gt;at most once per frame&lt;/strong&gt;, aligned with the browser's render schedule, rather than potentially dozens of times between frames.&lt;/p&gt;




&lt;h2&gt;
  
  
  IntersectionObserver vs scroll events
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IntersectionObserver&lt;/code&gt; is &lt;strong&gt;off the main thread&lt;/strong&gt;. The browser handles the intersection calculations in a separate process and delivers callbacks to your JavaScript only when intersection ratios change. This means it doesn't fire dozens of times per scroll  only when something actually enters or exits the viewport.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`js&lt;br&gt;
const observer = new IntersectionObserver((entries) =&amp;gt; {&lt;br&gt;
  entries.forEach(entry =&amp;gt; {&lt;br&gt;
    // This callback is batched and off-main-thread for intersection math&lt;br&gt;
    entry.target.classList.toggle('visible', entry.isIntersecting);&lt;br&gt;
  });&lt;br&gt;
}, { threshold: 0.1 });&lt;/p&gt;

&lt;p&gt;document.querySelectorAll('.animate-on-scroll').forEach(el =&amp;gt; observer.observe(el));&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Compare this to the scroll event approach that was common five years ago:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;js&lt;br&gt;
// Don't do this on mobile&lt;br&gt;
window.addEventListener('scroll', () =&amp;gt; {&lt;br&gt;
  document.querySelectorAll('.animate-on-scroll').forEach(el =&amp;gt; {&lt;br&gt;
    const rect = el.getBoundingClientRect(); // Forces layout!&lt;br&gt;
    if (rect.top &amp;lt; window.innerHeight) {&lt;br&gt;
      el.classList.add('visible');&lt;br&gt;
    }&lt;br&gt;
  });&lt;br&gt;
});&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The scroll version runs on the main thread, calls &lt;code&gt;getBoundingClientRect()&lt;/code&gt; on every element (which forces a layout flush), and executes potentially hundreds of times per second. On a Redmi Note 9, this will stutter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Input latency: the 300ms tap delay
&lt;/h2&gt;

&lt;p&gt;Until around 2017, mobile browsers introduced a 300ms delay before firing &lt;code&gt;click&lt;/code&gt; events from taps. The reason: the browser needed to wait to see if the tap was the first tap of a double-tap zoom gesture. This is now mostly resolved, but the fix is worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;css&lt;br&gt;
/* Eliminates 300ms tap delay on elements */&lt;br&gt;
.button {&lt;br&gt;
  touch-action: manipulation;&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;touch-action: manipulation&lt;/code&gt; tells the browser this element supports tap and pan but not double-tap-to-zoom, so the 300ms wait isn't needed. Modern browsers (Chrome 55+, Safari 13+) have removed the delay globally for pages with a &lt;code&gt;&amp;lt;meta name="viewport" content="width=device-width"&amp;gt;&lt;/code&gt; tag, but &lt;code&gt;touch-action: manipulation&lt;/code&gt; is belt-and-suspenders for interactive elements on older devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Images on mobile
&lt;/h2&gt;

&lt;p&gt;Image rendering is a surprising source of main thread cost on mobile:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`html&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Frenderlog.in%2Fimages%2Fhero-800.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Frenderlog.in%2Fimages%2Fhero-800.jpg" alt="..." width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The three attributes that matter for mobile performance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loading="lazy"&lt;/code&gt;&lt;/strong&gt;: defers fetching images outside the viewport. On a page with 20 images, this can save 5–10MB of initial load on mobile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;decoding="async"&lt;/code&gt;&lt;/strong&gt;: tells the browser to decode the image off the main thread. Without this, large image decodes happen synchronously and can spike frame times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sizes&lt;/code&gt;&lt;/strong&gt;: tells the browser which image to download based on the viewport width. Without accurate &lt;code&gt;sizes&lt;/code&gt;, the browser guesses wrong and often downloads the full-size image on a phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Always specify explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on images. Without them, the browser can't reserve space for the image before it loads, causing &lt;strong&gt;Cumulative Layout Shift&lt;/strong&gt;  which is even more jarring on mobile where reflows are slower.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The mobile performance debugging workflow I follow now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Profile on real hardware&lt;/strong&gt;  don't trust desktop throttle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check DOM node count&lt;/strong&gt;  anything over 1,500 nodes in the initial render deserves scrutiny&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;content-visibility: auto&lt;/code&gt;&lt;/strong&gt; to long-page sections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;{ passive: true }&lt;/code&gt;&lt;/strong&gt; to all scroll/touch listeners&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace scroll listeners with IntersectionObserver&lt;/strong&gt; where possible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for &lt;code&gt;getBoundingClientRect()&lt;/code&gt; in scroll handlers&lt;/strong&gt; (it forces layout)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Virtualize any list over 100 items&lt;/strong&gt;  use &lt;code&gt;react-virtuoso&lt;/code&gt; or &lt;code&gt;@tanstack/virtual&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set explicit image dimensions&lt;/strong&gt; and add &lt;code&gt;loading="lazy"&lt;/code&gt; + &lt;code&gt;decoding="async"&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Budget mobile devices are not edge cases: they're often the majority of your users' hardware outside of North America and Western Europe. Building with that constraint in mind from the start is dramatically cheaper than retrofitting it after your analytics start showing high bounce rates on Android.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/mobile-web-dom-performance/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/mobile-web-dom-performance/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you found this helpful, I've also built some free tools for developers and everyday users. Feel free to try them once:&lt;/p&gt;

&lt;p&gt;JSON Tools: &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt;&lt;br&gt;
Text Tools: &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt;&lt;br&gt;
QR Tools: &lt;a href="https://qr.renderlog.in" rel="noopener noreferrer"&gt;https://qr.renderlog.in&lt;/a&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>mobile</category>
      <category>dom</category>
      <category>css</category>
    </item>
    <item>
      <title>React Re-rendering: When and Why Component Trees Update</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sun, 26 Apr 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/react-re-rendering-when-and-why-component-trees-update-3plg</link>
      <guid>https://dev.to/helloashish99/react-re-rendering-when-and-why-component-trees-update-3plg</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/long-tasks-main-thread-blocking/" rel="noopener noreferrer"&gt;Long Tasks and Main Thread Blocking&lt;/a&gt;  heavy React renders are one of the most common sources of Long Tasks.&lt;/p&gt;

&lt;p&gt;React's default re-render behavior is intentionally conservative: when a parent re-renders, all children re-render too. This is correct by default — React prioritizes correctness over performance, and render-phase work (function calls, hook execution) is cheap enough that unnecessary re-renders are often harmless. But "often harmless" is not "always harmless."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; The exact four triggers that cause a component to re-render, how reconciliation and the fiber tree work, why referential equality matters for memoization, the context performance trap, and how to read the React DevTools Profiler to find the root cause of unexpected re-renders.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3roz6jejd9rv2mr1nwrq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3roz6jejd9rv2mr1nwrq.png" alt="Diagram of React's render phase versus commit phase: reconciliation produces an effect list, then the DOM is updated." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Render phase vs commit phase
&lt;/h2&gt;

&lt;p&gt;React's work of "updating the UI" is split into two fundamentally different phases. Confusing them is the root of a lot of performance misconceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The render phase
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;render phase&lt;/strong&gt; is when React calls your component functions and figures out what the UI &lt;em&gt;should&lt;/em&gt; look like. When you call &lt;code&gt;setState&lt;/code&gt;, React schedules a render. During the render phase, React:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calls the component function (your function component body executes).&lt;/li&gt;
&lt;li&gt;Calls all the hooks in order (&lt;code&gt;useState&lt;/code&gt;, &lt;code&gt;useEffect&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;li&gt;Gets the returned JSX.&lt;/li&gt;
&lt;li&gt;Does this recursively for any child components that also need updating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diffs&lt;/strong&gt; the new output against the previous output (reconciliation).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Critical insight:&lt;/strong&gt; the render phase is &lt;em&gt;pure work: React is just computing what the UI should be. **It doesn't touch the DOM yet.&lt;/em&gt;* Your component function can be called and produce output that React then decides to discard (this is what StrictMode's double-render exploits  see below).&lt;/p&gt;

&lt;h3&gt;
  
  
  The commit phase
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;commit phase&lt;/strong&gt; is when React actually applies changes to the DOM. It has three sub-phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before mutation&lt;/strong&gt;: fires &lt;code&gt;getSnapshotBeforeUpdate&lt;/code&gt; lifecycle and captures current DOM state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutation&lt;/strong&gt;: applies DOM insertions, updates, and deletions. This is the only time React directly touches the DOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout&lt;/strong&gt;: fires &lt;code&gt;useLayoutEffect&lt;/code&gt; and &lt;code&gt;componentDidMount&lt;/code&gt;/&lt;code&gt;componentDidUpdate&lt;/code&gt; synchronously. This is why &lt;code&gt;useLayoutEffect&lt;/code&gt; can measure DOM layout  the DOM is updated but the browser hasn't painted yet.&lt;/li&gt;
&lt;li&gt;After the commit, &lt;code&gt;useEffect&lt;/code&gt; callbacks are scheduled for after the browser has painted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Understanding this split matters because: &lt;strong&gt;render phase work is cheap to do multiple times&lt;/strong&gt; (it's just function calls and object comparisons). &lt;strong&gt;Commit phase work touches the DOM&lt;/strong&gt; and can trigger browser reflow. React is clever about doing as little DOM work as possible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reconciliation and the fiber tree
&lt;/h2&gt;

&lt;p&gt;React doesn't just compare new JSX against a flat list of DOM elements. It maintains an internal data structure called the &lt;strong&gt;fiber tree&lt;/strong&gt;  a graph of objects representing every component instance in your app, including their state, hooks, and pending work.&lt;/p&gt;

&lt;p&gt;Each &lt;strong&gt;fiber&lt;/strong&gt; corresponds to one component instance. When a state update is triggered, React creates an alternative "work in progress" fiber tree and starts reconciling it against the current tree. This is the reconciliation algorithm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What reconciliation does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compares new JSX element type against the existing fiber's type.&lt;/li&gt;
&lt;li&gt;If the &lt;strong&gt;type changed&lt;/strong&gt; (e.g., &lt;code&gt;&amp;lt;Button&amp;gt;&lt;/code&gt; became &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt;), React unmounts the old component tree and mounts a fresh one.&lt;/li&gt;
&lt;li&gt;If the &lt;strong&gt;type is the same&lt;/strong&gt;, React updates the existing fiber with new props, runs hooks again, and recurses into children.&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;lists&lt;/strong&gt;, it uses keys to match new elements to existing fibers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key performance insight: &lt;strong&gt;reconciliation is proportional to the size of the fiber tree that gets re-rendered&lt;/strong&gt;. If you trigger a re-render high in the tree, React walks down through all descendants. This is why the settings checkbox caused 47 re-renders  the &lt;code&gt;useState&lt;/code&gt; was placed in a component that was the parent of almost everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  What triggers a re-render
&lt;/h2&gt;

&lt;p&gt;There are exactly four causes of a React component re-rendering:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;setState call&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Calling the setter from &lt;code&gt;useState&lt;/code&gt; or &lt;code&gt;useReducer&lt;/code&gt; dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Props change&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Parent re-renders and passes new prop values (by reference)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Context value changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Any consumer of a context re-renders when the context value changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Parent re-renders&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A component re-renders when its parent re-renders, even if props didn't change&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fourth one surprises people the most. In React's default behavior, &lt;strong&gt;if a parent re-renders, all children re-render too&lt;/strong&gt;  regardless of whether their props changed. This is the default: without memoization, the component tree re-renders in a cascade from the component that triggered the state change, downward.&lt;/p&gt;

&lt;p&gt;This is actually a deliberate design choice. React assumes that re-rendering is cheap (it's just function calls) and that computing whether to skip a render is sometimes more expensive than just doing the render. The defaults are optimized for correctness, not maximum performance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Re-renders don't mean DOM updates
&lt;/h2&gt;

&lt;p&gt;This is a crucial clarification that trips up a lot of performance investigations.&lt;/p&gt;

&lt;p&gt;When 47 components re-render, React calls 47 component functions and gets back 47 JSX trees. Then it diffs those against the previous output. If the output is &lt;em&gt;identical&lt;/em&gt;, &lt;strong&gt;React makes zero DOM changes&lt;/strong&gt; for that component. No DOM mutations, no browser reflow, nothing.&lt;/p&gt;

&lt;p&gt;So "47 components re-rendered" in React DevTools means "47 component functions were called, not "47 DOM nodes were updated." The actual DOM impact depends on how many of those components produced &lt;em&gt;different&lt;/em&gt; output.&lt;/p&gt;

&lt;p&gt;This distinction matters because: pure render cost (function calls, hook execution) is usually cheap. DOM mutation cost is what triggers browser layout and paint. If your 47 re-rendering components all produce the same output as before, you might have wasted 2ms of JavaScript time, but you've caused zero additional browser rendering work.&lt;/p&gt;

&lt;p&gt;That said, 47 function calls isn't free: if any of those functions do expensive computations inline (without &lt;code&gt;useMemo&lt;/code&gt;), or if React itself has to run through complex reconciliation logic, you'll feel it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why referential equality matters
&lt;/h2&gt;

&lt;p&gt;This is where function components differ fundamentally from the mental model of "props changed = new values." React uses &lt;strong&gt;referential equality&lt;/strong&gt; (&lt;code&gt;===&lt;/code&gt;) to compare props and determine if a memoized component should skip its render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Parent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// ⚠️ New object reference created on every render&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// ⚠️ New function reference created on every render&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleClick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;


    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time &lt;code&gt;Parent&lt;/code&gt; re-renders, &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;handleClick&lt;/code&gt; are brand new objects. They're &lt;strong&gt;deeply equal&lt;/strong&gt; to the previous values (same shape, same content) but &lt;code&gt;config === previousConfig&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; because they're different object references.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;Settings&lt;/code&gt; is wrapped in &lt;code&gt;React.memo&lt;/code&gt;, it will &lt;em&gt;still re-render&lt;/em&gt; because its &lt;code&gt;config&lt;/code&gt; prop is a new reference, even though nothing meaningfully changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// useMemo stabilizes the reference between renders&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="c1"&gt;// useCallback stabilizes function references&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleClick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&lt;/code&gt; are not about avoiding "expensive computations: they're primarily about &lt;strong&gt;referential stability&lt;/strong&gt;. Their main job is to prevent downstream re-renders caused by new object/function references.&lt;/p&gt;




&lt;h2&gt;
  
  
  The context performance problem
&lt;/h2&gt;

&lt;p&gt;Context is often described as a solution for "prop drilling: passing data through many levels of components. It is that. It's also a performance footgun if you're not careful about what you put in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context is a broadcast.&lt;/strong&gt; When the context value changes, &lt;strong&gt;every component that consumes that context re-renders&lt;/strong&gt;, regardless of whether the specific piece of data it reads changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ⚠️ This context re-renders ALL consumers when ANYTHING in the object changes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AppContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;sidebarOpen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sidebarOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSidebarOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// New object reference when sidebarOpen changes&lt;/span&gt;
  &lt;span class="c1"&gt;// → Every context consumer re-renders, including deep UI components that only care about `user`&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sidebarOpen&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;AppContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Toggling the sidebar causes every consumer of &lt;code&gt;AppContext&lt;/code&gt; to re-render, including components that only ever read &lt;code&gt;user&lt;/code&gt;. The fix is to split context by update frequency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stable values that rarely change&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Dynamic values that change often&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UIStateContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sidebarOpen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Now sidebar state changes only affect UIStateContext consumers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A useful mental model: &lt;strong&gt;each context should have one reason to change&lt;/strong&gt;. If your context value object contains both stable auth data and frequently-changing UI state, you'll cause unnecessary re-renders across the entire consumer tree.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keys in lists: what they actually do
&lt;/h2&gt;

&lt;p&gt;Keys serve a specific mechanical purpose during reconciliation. React uses keys to &lt;strong&gt;match new list elements to existing fibers&lt;/strong&gt; when the list changes. Without keys (or with incorrect keys), React falls back to matching by position.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Without keys  React matches by index&lt;/span&gt;
&lt;span class="c1"&gt;// Adding an item to the beginning: React thinks EVERY item changed&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;))}&lt;/span&gt;

&lt;span class="c1"&gt;// With stable IDs: React correctly identifies which item was added&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you use &lt;code&gt;key={index}&lt;/code&gt; and prepend an item to the list, React sees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Position 0: had &lt;code&gt;{id: 1}&lt;/code&gt;, now has &lt;code&gt;{id: 0}&lt;/code&gt; → update this fiber&lt;/li&gt;
&lt;li&gt;Position 1: had &lt;code&gt;{id: 2}&lt;/code&gt;, now has &lt;code&gt;{id: 1}&lt;/code&gt; → update this fiber&lt;/li&gt;
&lt;li&gt;etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every fiber gets updated because &lt;em&gt;positions&lt;/em&gt; changed, even though only &lt;em&gt;one&lt;/em&gt; item was added. With stable IDs, React sees that existing fibers just shifted position and correctly reconciles with minimal work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The random-key anti-pattern&lt;/strong&gt; is even worse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ⚠️ Generates a new key on every render  destroys all reconciliation benefits&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With random keys, every render unmounts all existing Card components and mounts fresh ones. You lose all component state, all DOM node reuse, and get maximum mount/unmount work on every render.&lt;/p&gt;




&lt;h2&gt;
  
  
  React 18 automatic batching
&lt;/h2&gt;

&lt;p&gt;Before React 18, state updates inside &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;Promise.then&lt;/code&gt;, or native event handlers were processed individually: one &lt;code&gt;setState&lt;/code&gt; = one re-render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// React 17: 2 renders&lt;/span&gt;
&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Render 1&lt;/span&gt;
  &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// Render 2&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// React 18: 1 render (automatic batching)&lt;/span&gt;
&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Batched&lt;/span&gt;
  &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// Batched → single render&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React 18's &lt;strong&gt;automatic batching&lt;/strong&gt; extends the existing batching behavior (which previously only worked in React event handlers) to &lt;em&gt;all&lt;/em&gt; asynchronous contexts. This is a free performance improvement that many apps benefit from immediately after upgrading.&lt;/p&gt;

&lt;p&gt;The cases where this matters most: fetch callbacks that update multiple state values, async event handlers that set loading + data states together, and any code that does multiple &lt;code&gt;setState&lt;/code&gt; calls in a row in async code.&lt;/p&gt;

&lt;p&gt;If you need to explicitly opt out of batching (rare), &lt;code&gt;flushSync&lt;/code&gt; from &lt;code&gt;react-dom&lt;/code&gt; forces synchronous processing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;
&lt;span class="c1"&gt;// Forces immediate render after each setState&lt;/span&gt;
&lt;span class="nf"&gt;flushSync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nf"&gt;flushSync&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  StrictMode's double-render in development
&lt;/h2&gt;

&lt;p&gt;If you're using &lt;code&gt;React.StrictMode&lt;/code&gt; (you should be in development), your component functions are called &lt;strong&gt;twice&lt;/strong&gt; during the render phase in development mode. This is intentional and a source of confusion for developers who see their &lt;code&gt;console.log&lt;/code&gt; appearing twice.&lt;/p&gt;

&lt;p&gt;What StrictMode is doing: it deliberately calls your component function twice to check that the function is &lt;strong&gt;pure&lt;/strong&gt;  that calling it multiple times with the same inputs produces the same output. If your component has &lt;strong&gt;side effects&lt;/strong&gt; in the render phase (network requests, direct DOM mutations, setting external variables), the double-render will expose them because those effects will fire twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BadComponent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ⚠️ Side effect in render  this fires twice in StrictMode development&lt;/span&gt;
  &lt;span class="nx"&gt;someGlobalCounter&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;someGlobalCounter&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double-render only happens in &lt;strong&gt;development mode&lt;/strong&gt;. Production builds render once. If something works in development but breaks in production differently, StrictMode's double-render is not the cause  but the bugs it exposes &lt;em&gt;in development&lt;/em&gt; might surface as subtle issues in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading the React Profiler
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;React DevTools Profiler&lt;/strong&gt; is the right tool for understanding re-renders. Open DevTools → Components → Profiler. Hit Record, do the interaction that feels slow, stop recording.&lt;/p&gt;

&lt;p&gt;The flame chart shows every component that rendered, how long it took, and crucially  &lt;strong&gt;why it rendered&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Props changed&lt;/strong&gt;  one or more props have a different reference than the previous render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State changed&lt;/strong&gt;  a &lt;code&gt;useState&lt;/code&gt; or &lt;code&gt;useReducer&lt;/code&gt; hook in this component changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context changed&lt;/strong&gt;  a context this component subscribes to changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parent re-rendered&lt;/strong&gt;  no local reason, but the parent rendered so this did too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks changed&lt;/strong&gt;  a hook produced a different value than before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "why did this render?" information is invaluable for diagnosing unnecessary re-renders. When you see "parent re-rendered" for a component that shouldn't care about its parent's state change, that's the target for &lt;code&gt;React.memo&lt;/code&gt;. When you see "props changed" for a memoized component that's receiving an object prop, that's the target for &lt;code&gt;useMemo&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profiler "Why"&lt;/th&gt;
&lt;th&gt;Likely fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Parent rendered" on a pure display component&lt;/td&gt;
&lt;td&gt;Wrap with &lt;code&gt;React.memo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Props changed" on a memoized component&lt;/td&gt;
&lt;td&gt;Stabilize prop reference with &lt;code&gt;useMemo&lt;/code&gt;/&lt;code&gt;useCallback&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Context changed" but component only reads one field&lt;/td&gt;
&lt;td&gt;Split context by update frequency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"State changed"  unexpected&lt;/td&gt;
&lt;td&gt;Check if the state is co-located at the right level&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Profiler also shows &lt;strong&gt;total render duration&lt;/strong&gt; per component. If a single component consistently takes 15ms+ to render, that's a component-level optimization opportunity  either memoizing expensive calculations inside it or splitting it into smaller pieces.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/react-rerendering-when-trees-update/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/react-rerendering-when-trees-update/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you found this helpful, I've also built some free tools for developers and everyday users. Feel free to try them once:&lt;/p&gt;

&lt;p&gt;JSON Tools: &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt;&lt;br&gt;
Text Tools: &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt;&lt;br&gt;
QR Tools: &lt;a href="https://qr.renderlog.in" rel="noopener noreferrer"&gt;https://qr.renderlog.in&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>performance</category>
      <category>rendering</category>
      <category>reconciliation</category>
    </item>
    <item>
      <title>OCR in the Browser: How Tesseract.js Makes PDF Text Extraction Free</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:49:28 +0000</pubDate>
      <link>https://dev.to/helloashish99/ocr-in-the-browser-how-tesseractjs-makes-pdf-text-extraction-free-5ab2</link>
      <guid>https://dev.to/helloashish99/ocr-in-the-browser-how-tesseractjs-makes-pdf-text-extraction-free-5ab2</guid>
      <description>&lt;p&gt;You've got a 200-page PDF that someone scanned years ago. It's just images of pages — Cmd-F finds nothing. You need to extract the text, search through it, maybe paste a paragraph into a doc.&lt;/p&gt;

&lt;p&gt;Five years ago, this meant a cloud OCR API at $1.50 per 1,000 pages, plus uploading your potentially-sensitive PDF to a third-party service. Now it means dropping the file into a tab and waiting two minutes. The thing that made the difference is Tesseract.js — and understanding what it does, where it shines, and where it falls short is worth knowing whether you're building a tool or just trying to get text out of a scan.&lt;/p&gt;

&lt;p&gt;This post walks through how browser-based OCR actually works, what to expect from the open-source state of the art, and the engineering decisions that go into shipping it well.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OCR is, briefly
&lt;/h2&gt;

&lt;p&gt;Optical character recognition takes an image of text and produces actual text characters. Modern OCR engines do this in two stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layout analysis&lt;/strong&gt; — figure out where the text regions are on the page, in what order they should be read, and where lines and words break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Character recognition&lt;/strong&gt; — for each detected word, classify the visual pattern as one of the characters it could be.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 used to use rule-based image processing (look at the shape, match against templates). Modern engines including Tesseract use neural networks (LSTMs, mostly) trained on huge corpora of text in different fonts and conditions.&lt;/p&gt;

&lt;p&gt;The accuracy of a modern OCR engine on clean printed text is 95–99%. On handwriting, multi-column layouts, tables, or low-quality scans, it drops fast. We'll come back to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tesseract: 30 years of OCR, now in your browser
&lt;/h2&gt;

&lt;p&gt;Tesseract is the open-source OCR engine that's been around since 1985. HP wrote it, then it sat unused for a decade, then Google rescued it in 2005, rewrote the engine in 2018 to use LSTMs, and kept improving it. It supports 100+ languages, runs as a command-line tool, and is the engine behind a huge fraction of OCR products you've used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tesseract.js&lt;/strong&gt; is Tesseract compiled to WebAssembly. Same accuracy as the desktop version, runs in any modern browser, no server required. The whole thing is about 8MB compressed (engine + a single language pack), loads on demand, and processes pages at maybe 1–3 seconds each on a typical laptop.&lt;/p&gt;

&lt;p&gt;The basic usage is comically simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tesseract.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;imageOrCanvasOrUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eng&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Pass an image, get back text. The &lt;code&gt;logger&lt;/code&gt; is useful because OCR isn't instant — typical pages take a few seconds, and you want a progress bar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PDF-to-text pipeline
&lt;/h2&gt;

&lt;p&gt;Tesseract operates on images, not PDFs. So the full pipeline for "OCR a scanned PDF" is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the PDF (use pdf.js)&lt;/li&gt;
&lt;li&gt;Render each page to a canvas&lt;/li&gt;
&lt;li&gt;Pass the canvas to Tesseract.js&lt;/li&gt;
&lt;li&gt;Concatenate the results&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The skeleton looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;pdfjs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdfjs-dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tesseract.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ocrPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdfjs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;// 2x for OCR accuracy&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;canvasContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eng&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render at 2× or 3× scale.&lt;/strong&gt; OCR accuracy correlates strongly with resolution. The native PDF DPI (72 or 96) is usually too low; bumping to 2× makes a noticeable difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process pages sequentially.&lt;/strong&gt; Tesseract.js can run multiple workers in parallel, but each worker loads ~8MB of language data, so on memory-constrained devices, sequential is safer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show progress.&lt;/strong&gt; OCR is slow. A 50-page document at 2 seconds/page is 100 seconds — without a progress indicator, users think the page froze.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Web Workers for the UI
&lt;/h2&gt;

&lt;p&gt;If you call &lt;code&gt;Tesseract.recognize&lt;/code&gt; directly on the main thread, the page becomes janky during processing. Tesseract.js comes with built-in Worker support — every recognize call runs in a worker by default, but you can also pre-spin workers and reuse them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eng&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;canvases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recognize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reusing one worker for multiple pages avoids repeated language-data loading. For batches, this is 3–5× faster than the simple form above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language packs
&lt;/h2&gt;

&lt;p&gt;Tesseract supports 100+ languages, but each language is a separate trained data file (5–25MB compressed). You don't bundle them — you download on demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Tesseract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createWorker&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eng&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;spa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;// English + Spanish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a multilingual OCR app, the data-loading strategy matters. Don't ship all 100 language packs in your bundle; let users select languages and lazy-load.&lt;/p&gt;

&lt;p&gt;A few practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;English&lt;/strong&gt; is by far the best-tuned. CJK languages (Chinese, Japanese, Korean) work but are slower and slightly less accurate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed-language documents&lt;/strong&gt; are tricky. Tesseract supports passing multiple languages, but it tries to apply all of them to every word — this is slower and sometimes less accurate than running it in single-language mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Math and code&lt;/strong&gt; are recognized poorly. OCR engines were trained on natural-language text. Variable names, equations, and code samples often come out scrambled.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where OCR falls down
&lt;/h2&gt;

&lt;p&gt;Even on clean printed text, you'll hit cases that break:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low-resolution scans.&lt;/strong&gt; Anything below 200 DPI gets unreliable. Below 150 DPI, accuracy drops to 70–80%. If your input is a phone photo of a printed page taken in dim light, OCR will struggle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-column layouts.&lt;/strong&gt; Tesseract has a layout analyzer, but it sometimes reads across columns instead of down them, producing scrambled output. Newspapers and academic papers are the classic problem cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables.&lt;/strong&gt; Tesseract can extract the text from a table, but it loses the structure. You get a flat stream of cell contents in some order. For real tabular data extraction you need a different tool entirely (or a model fine-tuned on table layouts).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handwriting.&lt;/strong&gt; Out of scope for Tesseract. Use a model trained for handwriting (Google Cloud Vision, AWS Textract, or specialized libraries). The accuracy gap is enormous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low-contrast or skewed pages.&lt;/strong&gt; Pre-processing helps a lot. Convert to grayscale, increase contrast, deskew. There are JavaScript libraries (&lt;code&gt;opencv.js&lt;/code&gt;, &lt;code&gt;cv-tools&lt;/code&gt;) that do these transformations in the browser before OCR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forms and structured documents.&lt;/strong&gt; OCR gives you text. It doesn't tell you "this string is the patient's date of birth and this one is the diagnosis." For structured extraction, you need OCR + a separate parsing step (regex, NER models, or templated extraction).&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud OCR vs Tesseract.js — when to pick which
&lt;/h2&gt;

&lt;p&gt;Cloud OCR services (Google Cloud Vision, AWS Textract, Azure Computer Vision) are still better than Tesseract on hard cases. They handle handwriting, complex tables, multilingual documents, and edge cases that Tesseract struggles with. They're trained on far more data.&lt;/p&gt;

&lt;p&gt;But Tesseract.js wins on three axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt; — files never leave the browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt; — free, no per-page pricing, no API keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity&lt;/strong&gt; — no signup, no auth, no rate limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision rule: for printed-text OCR where you control the inputs (PDFs, screenshots, document scans), Tesseract.js is good enough most of the time. For high-stakes accuracy on edge-case inputs (handwritten forms, mixed handwriting/print, low-quality phone photos of receipts), use a cloud API.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical caveat: PDFs that already have text
&lt;/h2&gt;

&lt;p&gt;A surprising fraction of "scanned" PDFs actually have text in them — they were scanned, then put through an OCR pass at the printer or by another tool, and the text is embedded but invisible because the visual layer is the scan. Before running expensive OCR, check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTextContent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// PDF already has text — skip OCR&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Saves a lot of CPU when the work is already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to try OCR right now
&lt;/h2&gt;

&lt;p&gt;For a one-off (you have a scanned PDF, you need the text, you don't want to write code), &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;imagetools.renderlog.in&lt;/a&gt; and &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;pdftools.renderlog.in&lt;/a&gt; both run Tesseract.js client-side. The PDF tool's &lt;a href="https://pdftools.renderlog.in/ocr-pdf" rel="noopener noreferrer"&gt;OCR feature&lt;/a&gt; handles the pdf.js → canvas → Tesseract pipeline described above. Drop a PDF in, get text out, file never leaves the browser.&lt;/p&gt;

&lt;p&gt;For the actual implementation in your own app, the &lt;a href="https://github.com/naptha/tesseract.js" rel="noopener noreferrer"&gt;Tesseract.js GitHub repo&lt;/a&gt; is well-documented and the API hasn't changed much in years.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tesseract.js puts a battle-tested OCR engine in any modern browser at no per-page cost.&lt;/li&gt;
&lt;li&gt;The pipeline for OCRing a PDF is: pdf.js renders pages to canvases → Tesseract.js recognizes each canvas → concatenate the text.&lt;/li&gt;
&lt;li&gt;Render at 2–3× scale for accuracy. Use Web Workers (built in) to keep the UI responsive.&lt;/li&gt;
&lt;li&gt;Pre-check if PDFs already have a text layer before running OCR; many do.&lt;/li&gt;
&lt;li&gt;Tesseract is excellent on clean printed text, weak on handwriting, tables, and very low-quality inputs.&lt;/li&gt;
&lt;li&gt;For privacy-sensitive documents (medical, legal, contracts), client-side OCR removes the cloud-API trust problem entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Browser-based OCR went from "tech demo" to "ship this in production" in about three years. If you're still uploading scans to a paid API for printed-text extraction, it's worth a re-evaluation.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>machinelearning</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Client-Side PDF Tools Beat Server Uploads (Privacy, Speed, and Cost)</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:48:46 +0000</pubDate>
      <link>https://dev.to/helloashish99/why-client-side-pdf-tools-beat-server-uploads-privacy-speed-and-cost-21e1</link>
      <guid>https://dev.to/helloashish99/why-client-side-pdf-tools-beat-server-uploads-privacy-speed-and-cost-21e1</guid>
      <description>&lt;p&gt;You need to compress a PDF. You search "compress PDF online", land on a popular site, drag your file in, wait, download the result. Easy.&lt;/p&gt;

&lt;p&gt;Now let me ask: what was in that file? A passport scan? A signed contract? Your tax return? A medical report? An NDA from your employer?&lt;/p&gt;

&lt;p&gt;You uploaded it to a server. Their server, probably backed up, probably logged, probably handled by a third party. The terms of service almost certainly include something about "we may use your files to improve our service." Even if they delete it after an hour — and you're trusting them to — there's a window where it sat in cleartext on a machine you don't control.&lt;/p&gt;

&lt;p&gt;This isn't paranoid. This is what most "free PDF tools" actually do. And it's no longer necessary. Modern browsers can do almost everything those tools do, locally, in your tab, without uploading anything. This post is about why that's a meaningful shift, what made it possible, and what the trade-offs actually are.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we ended up uploading PDFs to servers
&lt;/h2&gt;

&lt;p&gt;Server-side became the default in the 2010s for a good reason: PDF parsing in JavaScript was awful. The format is decades-old, full of legacy quirks, supports embedded fonts and color profiles and JavaScript and form fields and digital signatures. Implementing even a slice of that in browser JavaScript meant slow, buggy code that worked on simple PDFs and crashed on complex ones.&lt;/p&gt;

&lt;p&gt;So PDF tool sites used the obvious workaround: spin up a backend in Python or Java, run a battle-tested library like &lt;code&gt;pdftk&lt;/code&gt;, &lt;code&gt;Ghostscript&lt;/code&gt;, or &lt;code&gt;iText&lt;/code&gt;, and let the browser just upload and download. It worked. Users didn't know — or didn't think about — what their files went through on the way there and back.&lt;/p&gt;

&lt;p&gt;Two things changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed: pdf.js and WebAssembly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pdf.js&lt;/strong&gt; is the PDF rendering library Mozilla built for Firefox in 2011. It's the engine that displays PDFs natively in the browser without a plugin. Over the years it grew capabilities beyond rendering — extracting text, manipulating pages, handling annotations. It's not perfect, but it's solid for 90% of real PDFs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebAssembly&lt;/strong&gt; changed the second half. Wasm lets you compile high-performance C, C++, and Rust code to a binary format that runs in the browser at near-native speed. Suddenly the same &lt;code&gt;Ghostscript&lt;/code&gt; or &lt;code&gt;MuPDF&lt;/code&gt; that powered server-side tools could run &lt;em&gt;inside the user's browser&lt;/em&gt;. Compression, OCR, format conversion — all the heavy work that used to require a Python service became a Wasm module loaded over HTTP.&lt;/p&gt;

&lt;p&gt;By 2022, the toolchain was ready: pdf.js for parsing, Wasm-compiled imaging libraries for compression and conversion, modern browser APIs (Web Workers, OffscreenCanvas) for keeping the UI responsive. A modern browser running on a modern laptop can compress a 50MB PDF faster than a free-tier server can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy angle
&lt;/h2&gt;

&lt;p&gt;This is the part that matters most to most users.&lt;/p&gt;

&lt;p&gt;When you upload a PDF to a server-side tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The file traverses your network in plaintext (well, TLS, but it leaves your machine encrypted-in-transit, decrypted-at-rest)&lt;/li&gt;
&lt;li&gt;It sits on a third-party machine for some window of time&lt;/li&gt;
&lt;li&gt;It may be backed up, cached at a CDN, logged, or analyzed&lt;/li&gt;
&lt;li&gt;The provider's privacy policy probably says they delete it after some time, but you have no way to verify&lt;/li&gt;
&lt;li&gt;A breach of that provider exposes every file uploaded that day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you process the same file in a client-side tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The file never leaves your browser&lt;/li&gt;
&lt;li&gt;No server has a copy, even temporarily&lt;/li&gt;
&lt;li&gt;A breach of the tool provider doesn't expose your files&lt;/li&gt;
&lt;li&gt;You can use it offline (after the JavaScript loads once)&lt;/li&gt;
&lt;li&gt;You can use it with files you legally aren't allowed to upload elsewhere (NDAs, classified-by-contract material, customer PII)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most personal use, this is "nice to have." For lawyers, medical professionals, government workers, or anyone subject to compliance regimes (HIPAA, GDPR, SOC 2), this is the difference between a tool you can use and one you can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The speed angle
&lt;/h2&gt;

&lt;p&gt;Counterintuitively, client-side is often &lt;em&gt;faster&lt;/em&gt; than server-side for everyday workflows. The reason: upload time.&lt;/p&gt;

&lt;p&gt;A 20MB PDF over a typical home internet upload (10–50 Mbps) takes 4–20 seconds just to reach the server. Then it processes. Then it downloads back. Round-trip on a typical residential connection: 15–45 seconds for processing that takes 2 seconds on the server itself.&lt;/p&gt;

&lt;p&gt;A client-side tool skips the round-trip entirely. The same 20MB PDF compresses in 5–10 seconds total, all CPU time, no network. On large files the client-side approach can be 5× faster end-to-end despite using slower hardware.&lt;/p&gt;

&lt;p&gt;The exception is genuinely heavy work — OCR on a 500-page scan, batch processing 100 PDFs at once, conversion to obscure formats. There the server's beefier CPU and shared GPUs win. For the 95% of PDF tasks that are merge, split, compress, and basic conversion, the client wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost angle (for tool builders)
&lt;/h2&gt;

&lt;p&gt;This one's for the developer in you, not the user.&lt;/p&gt;

&lt;p&gt;A server-side PDF tool pays per request. Even at $0.0001 per processed file, a tool that handles 100,000 PDFs/day costs $300/month in compute alone. Add bandwidth, storage, and the engineering time to keep the pipeline running. Most "free" PDF tools cover this cost with ads, upsells to premium tiers, or selling user data. None of those align the tool's incentives with yours.&lt;/p&gt;

&lt;p&gt;A client-side tool pays for the JavaScript bundle once, hosts static files on a CDN, and serves unlimited users at near-zero marginal cost. Your CPU does the work the server used to. The economics flip from "monetize per user" to "build it once, serve it forever."&lt;/p&gt;

&lt;p&gt;This is why client-side tools tend to be free without ads. They cost nothing to run, so they have no pressure to monetize aggressively.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs (because there always are some)
&lt;/h2&gt;

&lt;p&gt;Client-side PDF processing isn't a strict upgrade. The honest list:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File size limits.&lt;/strong&gt; Browser memory is finite. A 500MB PDF that runs fine on a server may crash a tab on a 4GB laptop. Tools usually cap at 100–200MB. If you're regularly working with multi-hundred-MB PDFs, server-side has more headroom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CPU intensity.&lt;/strong&gt; Heavy operations spin up the user's CPU. Mobile devices feel it. A 5-minute OCR pass on a 200-page scanned document drains battery and warms up the device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser compatibility.&lt;/strong&gt; Some operations require modern browser APIs. Old browsers (IE11, Safari before 14, in-app webviews) may not support all features. Modern client-side tools just refuse to load on these — usually fine, occasionally a problem in corporate environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Initial load.&lt;/strong&gt; The Wasm bundle and pdf.js together are 1–3MB of JavaScript. That's a one-time cost (cached after the first visit), but the first load is slower than a thin server-side tool's HTML page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Genuinely complex documents.&lt;/strong&gt; Encrypted PDFs with weird DRM, very old PDF/A archival files, ones with embedded JavaScript that interacts with form data — these still trip up client-side libraries more than mature server-side ones. For most real PDFs, it doesn't matter. For some workflows, it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell if a "free PDF tool" is actually client-side
&lt;/h2&gt;

&lt;p&gt;Before you upload anything sensitive, do one of these checks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Disconnect from the internet and try to use it.&lt;/strong&gt; Real client-side tools work offline (after the page has loaded once). Server-side tools fail with a network error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Open browser dev tools, go to the Network tab, and watch what happens when you submit a file.&lt;/strong&gt; A client-side tool shows zero meaningful network traffic — maybe an analytics ping, no file upload. A server-side tool shows a several-megabyte POST.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read the privacy policy.&lt;/strong&gt; Server-side tools have to mention file storage, retention, and processing. Client-side tools tend to say "your files never leave your device" and &lt;em&gt;can&lt;/em&gt; — that claim is verifiable, not marketing fluff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check for HTTPS only, no fancy infrastructure.&lt;/strong&gt; Client-side tools are usually static-hosted (Vercel, Netlify, Cloudflare Pages). The whole site is HTML + JS + Wasm, no backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's possible client-side now
&lt;/h2&gt;

&lt;p&gt;To give a sense of the current state — these are all things modern browsers handle locally without uploading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pdftools.renderlog.in/merge-pdf" rel="noopener noreferrer"&gt;Merge&lt;/a&gt;, &lt;a href="https://pdftools.renderlog.in/split-pdf" rel="noopener noreferrer"&gt;split&lt;/a&gt;, and reorder pages&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pdftools.renderlog.in/compress-pdf" rel="noopener noreferrer"&gt;Compress to a target file size&lt;/a&gt; (handy for email attachment limits)&lt;/li&gt;
&lt;li&gt;Convert &lt;a href="https://pdftools.renderlog.in/pdf-to-word" rel="noopener noreferrer"&gt;PDF to Word&lt;/a&gt; or images&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pdftools.renderlog.in/ocr-pdf" rel="noopener noreferrer"&gt;OCR scanned PDFs&lt;/a&gt; into searchable text&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pdftools.renderlog.in/sign-pdf" rel="noopener noreferrer"&gt;Sign PDFs&lt;/a&gt; with a drawn or typed signature&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pdftools.renderlog.in/unlock-pdf" rel="noopener noreferrer"&gt;Unlock password-protected PDFs&lt;/a&gt; (when you know the password)&lt;/li&gt;
&lt;li&gt;Image conversions: HEIC → JPG, WebP → PNG, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;pdftools.renderlog.in&lt;/a&gt; is one example built this way — everything client-side, no server-side processing of user files, works offline after the first load. It's the kind of tool that would have been impossible in 2015, viable but slow in 2020, and is just normal now.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Server-side PDF tools were a workaround for slow JavaScript that's no longer needed.&lt;/li&gt;
&lt;li&gt;Modern browsers + pdf.js + WebAssembly handle 95% of real PDF tasks locally, often faster than upload-process-download workflows.&lt;/li&gt;
&lt;li&gt;The privacy implications are real: files you process locally don't end up on someone else's servers.&lt;/li&gt;
&lt;li&gt;Cost economics flip from per-user pricing to free static hosting, which removes the pressure to monetize through ads or data.&lt;/li&gt;
&lt;li&gt;Trade-offs: file-size limits, CPU intensity on mobile, slightly larger initial bundle.&lt;/li&gt;
&lt;li&gt;To tell if a "free" PDF tool is actually client-side: try it offline, watch the Network tab, read the privacy policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next time a PDF tool asks for your file, ask where it's going. With the current state of web tech, the answer should be "nowhere." Anything else is a choice the tool builder made — usually for reasons that don't benefit you.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>A Developer's Guide to Image Formats: JPG, PNG, WebP, AVIF, and HEIC</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:48:04 +0000</pubDate>
      <link>https://dev.to/helloashish99/a-developers-guide-to-image-formats-jpg-png-webp-avif-and-heic-mmo</link>
      <guid>https://dev.to/helloashish99/a-developers-guide-to-image-formats-jpg-png-webp-avif-and-heic-mmo</guid>
      <description>&lt;p&gt;You're checking in 200 product photos. Should they be JPG? PNG? WebP? AVIF? Whatever the designer dragged in?&lt;/p&gt;

&lt;p&gt;This is the question every developer ducks until performance reviews force the conversation. The honest answer is "it depends" — but the dependencies are concrete and learnable, and once you know them you stop wasting bandwidth on the wrong format.&lt;/p&gt;

&lt;p&gt;This is a practical guide to the five formats you'll actually touch (JPG, PNG, WebP, AVIF, HEIC), plus a quick note on SVG and GIF. File sizes, when each wins, browser support, the conversion gotchas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision tree
&lt;/h2&gt;

&lt;p&gt;Before the deep dive, here's the rough mental model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You have...&lt;/th&gt;
&lt;th&gt;Use...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A photograph (smooth gradients, lots of color)&lt;/td&gt;
&lt;td&gt;JPG, or WebP/AVIF if you can serve them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A screenshot, diagram, logo, or any image with hard edges&lt;/td&gt;
&lt;td&gt;PNG, or WebP/AVIF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Something that needs transparency&lt;/td&gt;
&lt;td&gt;PNG, or WebP/AVIF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An image that needs to scale to any size (icons, logos)&lt;/td&gt;
&lt;td&gt;SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A short looping animation&lt;/td&gt;
&lt;td&gt;WebP, MP4, or GIF as last resort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A photo from an iPhone&lt;/td&gt;
&lt;td&gt;HEIC arriving, JPG/WebP serving&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table covers 90% of cases. The other 10% is in the details.&lt;/p&gt;

&lt;h2&gt;
  
  
  JPG (JPEG) — the workhorse
&lt;/h2&gt;

&lt;p&gt;Released 1992. Lossy compression tuned for photographs. Discards information humans don't notice — fine color variation in skies, tiny details in busy areas — and keeps file sizes small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; universal support, excellent compression for photos, predictable behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt; lossy (re-saving degrades quality), no transparency, terrible for text and hard edges (compression artifacts make text look fuzzy).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practical compression:&lt;/strong&gt; for photos served on the web, quality 75–85 is the sweet spot. Below 70 starts looking obviously compressed; above 90 wastes bytes for invisible quality. Most image tools default to quality 90 — too high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; don't open a JPG, edit it, and re-save it as JPG repeatedly. Each save is another lossy compression pass; after five or six rounds the photo looks visibly degraded. If you're going to edit, save as PNG or TIFF mid-flow, then export to JPG once at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  PNG — the lossless backbone
&lt;/h2&gt;

&lt;p&gt;Released 1996. Lossless compression, supports transparency, supports indexed color (palette-based) for tiny file sizes on simple graphics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; transparency, lossless (can re-save infinitely), great for screenshots and graphics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt; much larger than JPG for photos. A 1MB photo as JPG might be 4MB as PNG.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two PNG modes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PNG-24:&lt;/strong&gt; 16 million colors plus alpha transparency. What most tools save by default. Best for photos and complex graphics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG-8:&lt;/strong&gt; 256-color palette plus optional 1-bit transparency. What every old icon and screenshot used. Tiny file sizes for simple images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've got a logo with three colors on it, PNG-8 is often 10× smaller than PNG-24 with no visible difference. Most modern tools auto-pick or let you choose; older ones default to PNG-24 and waste space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use PNG over WebP/AVIF:&lt;/strong&gt; when you need maximum compatibility (very old browsers, email clients, system-level previews on outdated OSes) or when you need lossless preservation for further editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebP — Google's modern compromise
&lt;/h2&gt;

&lt;p&gt;Released 2010, mainstream-ready by 2018. Both lossy and lossless modes, supports transparency, supports animation. Compresses 25–35% smaller than JPG at equivalent quality, 26% smaller than PNG losslessly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; smaller files than JPG/PNG with comparable quality, transparency, animation, supported in every modern browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt; the encoder is slower than JPG. Not supported in some legacy email clients or very old browsers (IE11, but we don't care). Some image-editing tools still don't open WebP natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser support:&lt;/strong&gt; universal in modern browsers as of 2020. Safari was last to adopt it (Safari 14, 2020). If you can require Safari 14+, you can serve WebP unconditionally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Practical note:&lt;/strong&gt; WebP wins biggest on photos that have lots of smooth gradients. On hard-edged graphics (UI screenshots, line art) the savings over PNG are smaller — sometimes WebP is even larger. Test both before assuming WebP is smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  AVIF — the newest, smallest, slowest to encode
&lt;/h2&gt;

&lt;p&gt;Based on the AV1 video codec. Released 2019, broadly supported by 2023. Compresses 30–50% smaller than JPG at similar quality, often 20% smaller than WebP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; the smallest file size of any common format. Supports transparency, HDR, and wide color gamuts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt; slow to encode (5–10× slower than JPG), some image tools and CDNs still don't support it, decoding on low-end devices can be slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser support:&lt;/strong&gt; Chrome 85+, Firefox 93+, Safari 16.1+. As of 2026, support is broad enough to ship — but always serve a JPG/WebP fallback for the long tail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use:&lt;/strong&gt; when you have build-time image optimization, you're shipping a lot of photos, and bandwidth or LCP scores matter. For one-off images uploaded by users at runtime, the encoding cost may not be worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  HEIC — the iPhone format your build pipeline doesn't speak
&lt;/h2&gt;

&lt;p&gt;HEIC (High-Efficiency Image Container) is what iPhones save photos as by default since iOS 11 (2017). Compresses about 50% smaller than JPG at equivalent quality. Apple ecosystem treats it as native; everything else treats it as a problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; dramatic file-size savings, supports HDR, transparency, and burst sequences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaknesses:&lt;/strong&gt; patent-encumbered, no native browser support (Safari supports it system-wide but not in &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags), most non-Apple tools can't open it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The practical issue:&lt;/strong&gt; users upload HEICs to your web app from iPhones, and your backend can't process them. They show up as broken images, fail to thumbnail, error out in image-processing pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solutions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server-side conversion&lt;/strong&gt; to JPG or WebP on upload. Libraries like &lt;code&gt;heif-convert&lt;/code&gt; or &lt;code&gt;sharp&lt;/code&gt; (with libheif) handle this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-side conversion before upload&lt;/strong&gt;, using a HEIC-to-JPG tool. There's a &lt;a href="https://imagetools.renderlog.in/heic-to-jpg" rel="noopener noreferrer"&gt;browser-based HEIC to JPG converter&lt;/a&gt; at imagetools.renderlog.in if you want to test what your users are uploading. It runs entirely in the browser, so the photo never leaves the device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure iPhone to save as JPG.&lt;/strong&gt; Settings → Camera → Formats → Most Compatible. Most users don't know this exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  SVG and GIF — quick mentions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SVG (Scalable Vector Graphics).&lt;/strong&gt; Vector format, scales infinitely, tiny file sizes for icons and logos. Use for: icons, logos, simple illustrations. Don't use for: photographs (impossible) or anything with realistic shading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIF.&lt;/strong&gt; Released 1987. Use for: nothing, ideally. Supports animation but at terrible compression — a 5-second GIF is often 10× larger than the same content as an MP4 or WebP. The only legitimate modern use is as a fallback for environments that don't support video tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving multiple formats
&lt;/h2&gt;

&lt;p&gt;The browser-friendly way to serve modern formats with fallbacks is the &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"hero.avif"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/avif"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"hero.webp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"hero.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Hero image"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browsers pick the first format they support. If all three are present, modern browsers get AVIF, slightly older ones get WebP, anything else falls back to JPG.&lt;/p&gt;

&lt;p&gt;The build pipeline to generate all three is straightforward with tools like &lt;code&gt;sharp&lt;/code&gt;, &lt;code&gt;squoosh&lt;/code&gt;, or &lt;code&gt;vite-imagetools&lt;/code&gt;. If you're not generating multiple formats automatically, you're leaving 20–40% of bandwidth on the floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common conversions and where they bite
&lt;/h2&gt;

&lt;p&gt;A few real-world conversion paths and what to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JPG → WebP:&lt;/strong&gt; safe and lossless if you keep quality high; verify color space (some tools accidentally drop sRGB profiles).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG → JPG:&lt;/strong&gt; loses transparency. Anything that was transparent becomes opaque white (or black, depending on the tool).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC → JPG:&lt;/strong&gt; lossy; once converted, you can't get the HEIC quality back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebP → PNG:&lt;/strong&gt; lossless if the source was lossless WebP; lossy WebP converted to PNG looks fine but doesn't recover detail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AVIF → anything:&lt;/strong&gt; generally works, but very high-quality AVIF can produce huge PNGs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're doing one-off conversions while building, &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;imagetools.renderlog.in&lt;/a&gt; has dedicated converters for the common pairs — &lt;a href="https://imagetools.renderlog.in/webp-to-jpg" rel="noopener noreferrer"&gt;WebP to JPG&lt;/a&gt;, &lt;a href="https://imagetools.renderlog.in/heic-to-jpg" rel="noopener noreferrer"&gt;HEIC to JPG&lt;/a&gt;, &lt;a href="https://imagetools.renderlog.in/jpg-to-webp" rel="noopener noreferrer"&gt;JPG to WebP&lt;/a&gt;, &lt;a href="https://imagetools.renderlog.in/png-to-webp" rel="noopener noreferrer"&gt;PNG to WebP&lt;/a&gt;, and an &lt;a href="https://imagetools.renderlog.in/image-compressor" rel="noopener noreferrer"&gt;image compressor&lt;/a&gt; for size targets. All client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPG&lt;/td&gt;
&lt;td&gt;Photos, broad compatibility&lt;/td&gt;
&lt;td&gt;Quality 75–85 sweet spot; don't re-save repeatedly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;Screenshots, transparency, graphics&lt;/td&gt;
&lt;td&gt;PNG-8 for simple images is often 10× smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;Modern web replacement for JPG/PNG&lt;/td&gt;
&lt;td&gt;25–35% smaller, universal support since 2020&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;Photos with build-time optimization&lt;/td&gt;
&lt;td&gt;Smallest, slowest to encode, ship with fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC&lt;/td&gt;
&lt;td&gt;Receiving from iPhones&lt;/td&gt;
&lt;td&gt;Convert to JPG/WebP on upload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG&lt;/td&gt;
&lt;td&gt;Vector graphics&lt;/td&gt;
&lt;td&gt;Icons, logos, never photos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GIF&lt;/td&gt;
&lt;td&gt;Legacy fallback only&lt;/td&gt;
&lt;td&gt;Use WebP or video instead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Set your build pipeline to generate AVIF + WebP + JPG, serve them through &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;, and forget about format until the next refactor. Your users save bandwidth, you save Core Web Vitals points, everyone wins.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>frontend</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building Slugs That Don't Break: Unicode, Diacritics, and Edge Cases</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:47:22 +0000</pubDate>
      <link>https://dev.to/helloashish99/building-slugs-that-dont-break-unicode-diacritics-and-edge-cases-an6</link>
      <guid>https://dev.to/helloashish99/building-slugs-that-dont-break-unicode-diacritics-and-edge-cases-an6</guid>
      <description>&lt;p&gt;You ship a blog. The first international post is titled "Café au Lait — A Morning Routine." Your slug generator turns that into &lt;code&gt;/caf-au-lait--a-morning-routine&lt;/code&gt;. The double hyphen is ugly, the dropped accent is worse, and that's just the start of what naive slug generation gets wrong.&lt;/p&gt;

&lt;p&gt;This is one of those problems that looks like it deserves five lines of regex and ends up needing four hours and a battle-tested library. Let's walk through what actually goes wrong, why, and the rules a slug generator should follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a slug needs to be
&lt;/h2&gt;

&lt;p&gt;A slug is the human-readable part of a URL: in &lt;code&gt;/blog/why-rust-matters&lt;/code&gt;, the slug is &lt;code&gt;why-rust-matters&lt;/code&gt;. Good slugs have four properties:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;URL-safe&lt;/strong&gt; — contains only characters that don't need percent-encoding in a URL path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readable&lt;/strong&gt; — a human can guess what the page is about from the slug alone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable&lt;/strong&gt; — the same input produces the same slug, forever&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique&lt;/strong&gt; — within whatever scope (your blog, your products) two pieces of content don't collide&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The naive approach trips on every single one of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-line slug generator and why it's broken
&lt;/h2&gt;

&lt;p&gt;Most engineers, including me, the first time, write something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^-|-$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works on &lt;code&gt;"Hello World"&lt;/code&gt; → &lt;code&gt;"hello-world"&lt;/code&gt;. It also produces these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"Café au Lait"&lt;/code&gt; → &lt;code&gt;"caf-au-lait"&lt;/code&gt; (lost the accent, ugly)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"100% Pure"&lt;/code&gt; → &lt;code&gt;"100-pure"&lt;/code&gt; (dropped the meaning)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"C++ Programming"&lt;/code&gt; → &lt;code&gt;"c-programming"&lt;/code&gt; (lost the distinguishing feature)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"日本語入門"&lt;/code&gt; → &lt;code&gt;""&lt;/code&gt; (empty string — the entire title is gone)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"Hello   World"&lt;/code&gt; → &lt;code&gt;"hello---world"&lt;/code&gt; (multiple spaces become multiple hyphens)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"Hello-World"&lt;/code&gt; → &lt;code&gt;"hello-world"&lt;/code&gt; (collides with the natural slug)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And these are just the easy cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Unicode normalization
&lt;/h2&gt;

&lt;p&gt;The first thing a real slug generator does is &lt;em&gt;normalize&lt;/em&gt; Unicode. The character &lt;code&gt;é&lt;/code&gt; can be represented two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NFC (composed):&lt;/strong&gt; one code point, &lt;code&gt;U+00E9&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NFD (decomposed):&lt;/strong&gt; two code points, &lt;code&gt;e&lt;/code&gt; (&lt;code&gt;U+0065&lt;/code&gt;) followed by &lt;code&gt;◌́&lt;/code&gt; (&lt;code&gt;U+0301&lt;/code&gt;, combining acute accent)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These look identical on screen but have different byte sequences. If your slug code only handles one form, the other slips through unchanged.&lt;/p&gt;

&lt;p&gt;The fix is simple — normalize first, strip the diacritics second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stripDiacritics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;0300-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;036f&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;stripDiacritics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Café au Lait&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// → "Cafe au Lait"&lt;/span&gt;
&lt;span class="nf"&gt;stripDiacritics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;naïve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// → "naive"&lt;/span&gt;
&lt;span class="nf"&gt;stripDiacritics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Renée&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// → "Renee"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;\u0300-\u036f&lt;/code&gt; range covers the combining marks block — once &lt;code&gt;é&lt;/code&gt; is decomposed into &lt;code&gt;e&lt;/code&gt; + combining accent, the regex strips just the accent.&lt;/p&gt;

&lt;p&gt;This handles most European languages but not all of them. German &lt;code&gt;ß&lt;/code&gt; doesn't decompose; it should be transliterated to &lt;code&gt;ss&lt;/code&gt;. Polish &lt;code&gt;ł&lt;/code&gt; doesn't decompose either. For broad European coverage you need a transliteration map, not just NFD normalization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Transliteration vs dropping
&lt;/h2&gt;

&lt;p&gt;For non-Latin scripts (Chinese, Japanese, Arabic, Hindi, Cyrillic), you have a real decision to make:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Transliterate.&lt;/strong&gt; &lt;code&gt;日本語&lt;/code&gt; becomes &lt;code&gt;nihongo&lt;/code&gt;. The slug is readable to a Latin-alphabet reader, but transliteration is lossy and language-specific (&lt;code&gt;東京&lt;/code&gt; → &lt;code&gt;tokyo&lt;/code&gt; requires knowing it's Japanese, not Chinese, where it'd be &lt;code&gt;dongjing&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: Pass through.&lt;/strong&gt; Modern URLs support Unicode. &lt;code&gt;/日本語&lt;/code&gt; is a valid URL, browsers display it correctly, and search engines index it. The slug becomes meaningful to readers of that language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: Generate from a separate field.&lt;/strong&gt; Many blogs let authors set a slug manually for non-Latin titles. The slug is whatever the author types, the title is whatever they meant.&lt;/p&gt;

&lt;p&gt;There's no universally right answer. WordPress transliterates by default. Ghost passes through. Most documentation systems use option C. Pick based on your audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Punctuation that means something
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;100% off&lt;/code&gt; shouldn't become &lt;code&gt;100-off&lt;/code&gt;. The &lt;code&gt;%&lt;/code&gt; carries information. Battle-tested slug libraries have a &lt;em&gt;symbol map&lt;/em&gt; that converts meaningful punctuation into words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;symbolMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;and&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;percent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dollar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;€&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;euro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;£&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is opinionated — &lt;code&gt;100% off&lt;/code&gt; → &lt;code&gt;100-percent-off&lt;/code&gt; is more readable than &lt;code&gt;100-off&lt;/code&gt;, but plenty of teams just drop the symbol. Decide once, document it.&lt;/p&gt;

&lt;p&gt;For programming languages and tech terms specifically: &lt;code&gt;C++&lt;/code&gt; → &lt;code&gt;cpp&lt;/code&gt;, &lt;code&gt;C#&lt;/code&gt; → &lt;code&gt;csharp&lt;/code&gt;, &lt;code&gt;.NET&lt;/code&gt; → &lt;code&gt;dotnet&lt;/code&gt;. These are conventions, not deductions; a generic slug library won't get them right unless you tell it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The collision problem
&lt;/h2&gt;

&lt;p&gt;You publish "Hello World." The slug is &lt;code&gt;hello-world&lt;/code&gt;. Six months later, you publish another "Hello World" — maybe a follow-up, maybe a different topic that happens to share a title. What's the second slug?&lt;/p&gt;

&lt;p&gt;Common patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numeric suffix:&lt;/strong&gt; &lt;code&gt;hello-world&lt;/code&gt;, &lt;code&gt;hello-world-2&lt;/code&gt;, &lt;code&gt;hello-world-3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date suffix:&lt;/strong&gt; &lt;code&gt;hello-world-2026-04&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ID suffix:&lt;/strong&gt; &lt;code&gt;hello-world-a3f9&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The numeric suffix is the most common and almost always wrong. It encourages people to delete and republish to "get the clean URL", which breaks every link to the original. Date suffixes are the most stable. ID suffixes look ugly but never collide.&lt;/p&gt;

&lt;p&gt;Whatever you pick, &lt;strong&gt;never silently overwrite an existing slug&lt;/strong&gt;. Either reject the new content with an error, or generate a unique variant. Slugs that change break every backlink, RSS feed, social share, and search index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Reserved words
&lt;/h2&gt;

&lt;p&gt;If your slug generator ever produces &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;login&lt;/code&gt;, &lt;code&gt;logout&lt;/code&gt;, &lt;code&gt;settings&lt;/code&gt;, &lt;code&gt;signup&lt;/code&gt;, &lt;code&gt;register&lt;/code&gt;, or &lt;code&gt;dashboard&lt;/code&gt;, you've got a problem. Either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The slug now masks an actual route (&lt;code&gt;/blog/admin&lt;/code&gt; works fine, but &lt;code&gt;/admin&lt;/code&gt; doesn't)&lt;/li&gt;
&lt;li&gt;Or, worse, the route works and a user can SEO-impersonate your admin page&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Real slug libraries maintain a reserved-words list. Yours should too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RESERVED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;logout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;register&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;help&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;support&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;about&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...add anything specific to your app&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RESERVED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-post`&lt;/span&gt;  &lt;span class="c1"&gt;// or reject&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Length limits
&lt;/h2&gt;

&lt;p&gt;There's no formal URL length limit, but practical ones exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Most CDNs and proxies cap at 2KB&lt;/strong&gt; for the full URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email clients truncate links over 80 characters&lt;/strong&gt; in plain-text emails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search engines display only the first ~60 characters&lt;/strong&gt; of a slug in results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cap slugs at 60–80 characters, truncated at a word boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;lastIndexOf('-', max)&lt;/code&gt; ensures we cut at a hyphen, not mid-word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it all together
&lt;/h2&gt;

&lt;p&gt;A real slug function looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;maxLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFKD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;0300-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;036f&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// strip diacritics&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; and &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;// expand symbols&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;%&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; percent &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;// non-alphanumeric → hyphen&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^-+|-+$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;// trim hyphens&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;// re-trim after slice&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the floor. From there you'd add the reserved-words check, the collision handler, and (for non-Latin support) either transliteration or pass-through Unicode handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a library — but know what it does
&lt;/h2&gt;

&lt;p&gt;For production use, don't write this yourself. Battle-tested options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;slugify&lt;/code&gt;&lt;/strong&gt; (npm) — handles transliteration for major European languages, fast, good defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@sindresorhus/slugify&lt;/code&gt;&lt;/strong&gt; — more aggressive transliteration, more configuration knobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;github-slugger&lt;/code&gt;&lt;/strong&gt; — what GitHub uses for anchor links in READMEs. Predictable, simple.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;speakingurl&lt;/code&gt;&lt;/strong&gt; — the most thorough, supports the most languages, also the most overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a one-off — generating a slug while drafting, testing edge cases, or comparing two slug strategies — paste the title into a &lt;a href="https://text.renderlog.in/slug-generator" rel="noopener noreferrer"&gt;browser-based slug generator&lt;/a&gt; and see what falls out. It runs locally, so internal product names and unreleased post titles don't end up on a third-party server.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The five-line slug regex breaks on Unicode, symbols, collisions, and reserved words.&lt;/li&gt;
&lt;li&gt;Normalize Unicode (&lt;code&gt;NFD&lt;/code&gt;), strip combining marks, decide between transliterate vs pass-through for non-Latin scripts.&lt;/li&gt;
&lt;li&gt;Map meaningful symbols (&lt;code&gt;%&lt;/code&gt; → &lt;code&gt;percent&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt; → &lt;code&gt;and&lt;/code&gt;) — don't silently drop them.&lt;/li&gt;
&lt;li&gt;Maintain a reserved-words list. Cap slugs at 60–80 chars, cut on word boundaries.&lt;/li&gt;
&lt;li&gt;Never silently overwrite a slug; suffix or reject. Backlinks break forever otherwise.&lt;/li&gt;
&lt;li&gt;For everything beyond a one-off, use a library — &lt;code&gt;slugify&lt;/code&gt; or &lt;code&gt;@sindresorhus/slugify&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Slug generation is one of those problems that's easy to get 80% right and hard to get the last 20%. Worth doing properly once, then forgetting about.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>seo</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building Offline-First Web Apps with localStorage: A Practical Guide</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:46:32 +0000</pubDate>
      <link>https://dev.to/helloashish99/building-offline-first-web-apps-with-localstorage-a-practical-guide-5akk</link>
      <guid>https://dev.to/helloashish99/building-offline-first-web-apps-with-localstorage-a-practical-guide-5akk</guid>
      <description>&lt;p&gt;You're building a tiny tool. Maybe a notepad. Maybe a settings panel. Maybe a draft autosave for a form. You don't want a database. You don't want a backend. You don't even want a login.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;localStorage.setItem(key, value)&lt;/code&gt; solves the problem in one line and you go home. Until your tool grows up, the data outgrows 5MB, three browser tabs trip over each other, the user clears their cookies, and suddenly you have a support ticket that boils down to "I lost my work."&lt;/p&gt;

&lt;p&gt;This is the localStorage post I wish I'd had three years ago. When it's the right tool, when it isn't, and the specific footguns that make production-grade local persistence harder than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What localStorage actually is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;localStorage&lt;/code&gt; is a synchronous key-value store, scoped to an origin (protocol + domain + port), persisted to disk by the browser. Both keys and values are strings. That's the whole interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jane&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 'jane'&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's been in every browser since 2009. It has no expiration, survives reboots, and is shared across all tabs of the same origin. It's also the most misunderstood persistence API on the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  When localStorage is actually the right tool
&lt;/h2&gt;

&lt;p&gt;It's right for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User preferences&lt;/strong&gt; — theme, language, layout choices, "I dismissed this banner."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft state&lt;/strong&gt; — a form you want to autosave, a half-written note, an unsaved edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth-adjacent data&lt;/strong&gt; — user ID, role, last-known username (NOT auth tokens; we'll come back to that).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small caches&lt;/strong&gt; — a list of recent searches, a sidebar's collapsed/expanded state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature flags&lt;/strong&gt; that the user controls — "I opted into the beta UI."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern: small, simple, doesn't need querying, fits in tens of KB, and losing it is annoying but not catastrophic.&lt;/p&gt;

&lt;p&gt;It's wrong for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Large datasets&lt;/strong&gt; — anything past 1–2 MB starts to pinch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data you need to query&lt;/strong&gt; — localStorage is dumb storage; no indexes, no transactions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything sensitive&lt;/strong&gt; — accessible by any JS on the page (XSS = full data theft)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data shared across origins&lt;/strong&gt; — localStorage is origin-scoped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-write-frequency state&lt;/strong&gt; — synchronous writes block the main thread&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The footguns
&lt;/h2&gt;

&lt;p&gt;Most production localStorage bugs come from one of these.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Everything is a string
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;setItem&lt;/code&gt; stringifies whatever you pass it, but not in a clever way. Numbers become strings. Booleans become strings. Objects become &lt;code&gt;"[object Object]"&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// '5' — string, not number&lt;/span&gt;

&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 'true' — string&lt;/span&gt;

&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// '[object Object]' — useless&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always &lt;code&gt;JSON.stringify&lt;/code&gt; and &lt;code&gt;JSON.parse&lt;/code&gt; for non-string data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrap functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try/catch&lt;/code&gt; is not optional. If the user (or a previous bug) wrote bad JSON, &lt;code&gt;JSON.parse&lt;/code&gt; throws, your app breaks on load, and now you can't even fix it through normal flows because the broken data is the first thing you read.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The 5MB limit (which isn't really 5MB)
&lt;/h3&gt;

&lt;p&gt;The spec says "the user agent should limit the total amount of space allowed for storage areas." Most browsers settled on 5MB per origin, but it's not enforced uniformly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome:&lt;/strong&gt; ~5MB per origin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox:&lt;/strong&gt; 5MB per origin, configurable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari:&lt;/strong&gt; ~5MB; sometimes silently caps at less&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile Safari&lt;/strong&gt; in private mode: 0 (writes throw &lt;code&gt;QuotaExceededError&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 5MB is total — keys + values + a small overhead. Strings are stored as UTF-16, so each character is 2 bytes; a "5MB" budget is really 2.5 million characters.&lt;/p&gt;

&lt;p&gt;When you exceed the limit, &lt;code&gt;setItem&lt;/code&gt; throws a &lt;code&gt;DOMException&lt;/code&gt;. You need to catch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bigValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;QuotaExceededError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Make space, or warn the user, or fall back to IndexedDB&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're storing user-generated content, this happens in production eventually. Plan for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The synchronous API blocks the main thread
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;setItem&lt;/code&gt; call writes to disk synchronously. For small values, this is microseconds. For large values, especially on mobile, it can be tens of milliseconds — long enough to drop a frame.&lt;/p&gt;

&lt;p&gt;If your app is autosaving every keystroke and saving 100KB of state each time, you've got a stutter problem. The fix: debounce.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;saveTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scheduleSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saveTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;saveTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For genuinely large state, this still won't be enough. That's the IndexedDB threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Cross-tab synchronization
&lt;/h3&gt;

&lt;p&gt;Two tabs of your app are open. The user changes a setting in tab A. Tab B doesn't know — it's still showing the old value.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;storage&lt;/code&gt; event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;applyTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch: the &lt;code&gt;storage&lt;/code&gt; event only fires in &lt;em&gt;other&lt;/em&gt; tabs, not the one that did the write. So the writer needs to update its own UI separately, and the listeners pick up changes from elsewhere. This is correct behavior but easy to mishandle.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Versioning your stored data
&lt;/h3&gt;

&lt;p&gt;You ship v1 of your app. It writes user state shaped like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jane"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"blue"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six months later, you ship v2. The state shape is now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jane"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"preferences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"blue"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Existing users have v1 data sitting in localStorage. When v2 loads it, things break.&lt;/p&gt;

&lt;p&gt;The fix: version your stored data and migrate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CURRENT_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;defaultState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;migrate1to2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;
  &lt;span class="c1"&gt;// Unknown future version — bail&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;defaultState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migration is a one-way door. Once you ship v2, you can't easily walk it back without losing data. So design migrations carefully and test them with real v1 data before deploying.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Don't store auth tokens here
&lt;/h3&gt;

&lt;p&gt;This is its own essay, but quickly: any XSS vulnerability on your site lets an attacker do &lt;code&gt;localStorage.getItem('auth-token')&lt;/code&gt; and steal it. Cookies marked &lt;code&gt;httpOnly&lt;/code&gt; aren't accessible to JavaScript, which is what you want for credentials. Use cookies for auth; use localStorage for non-sensitive state.&lt;/p&gt;

&lt;p&gt;If you're inheriting a codebase that puts JWT tokens in localStorage, plan to migrate. The migration isn't trivial (CSRF protection, cross-origin handling) but it's worth doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to graduate to IndexedDB
&lt;/h2&gt;

&lt;p&gt;The threshold is roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data above 5MB&lt;/strong&gt; — localStorage will cap out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need querying&lt;/strong&gt; — finding records by anything other than primary key&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many writes per second&lt;/strong&gt; — IndexedDB is async and won't block the main thread&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary data&lt;/strong&gt; — Blobs, Files, ArrayBuffers store natively in IndexedDB; localStorage forces base64 encoding (33% size overhead)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;IndexedDB is more complex (the API is famously verbose), but libraries like &lt;code&gt;idb-keyval&lt;/code&gt; give you a localStorage-like wrapper for the simple cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idb-keyval&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;complexObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-state&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's IndexedDB with the same ergonomics as localStorage, but without the 5MB limit and without blocking.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real case study: a private notepad
&lt;/h2&gt;

&lt;p&gt;A small browser-based notepad — text editor, multiple tabs, autosave, no signup, fully local — is the kind of app that's a perfect localStorage fit until it isn't.&lt;/p&gt;

&lt;p&gt;The version that works for 95% of users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notepad-tabs-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadTabs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORAGE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveTabs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STORAGE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Debounce on every keystroke&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debouncedSave&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saveTabs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works perfectly until a user pastes a 4MB log file into one tab. Then &lt;code&gt;setItem&lt;/code&gt; throws, the tab data fails to save, and on reload everything is back to the previous state. The user thinks they lost their work.&lt;/p&gt;

&lt;p&gt;The graduation path: when total size approaches 1MB, migrate that tab's content to IndexedDB and store just a pointer in localStorage. Most users never trip the migration; the heavy users get a more capable backend automatically.&lt;/p&gt;

&lt;p&gt;This is what &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;notepad.renderlog.in&lt;/a&gt; does — small notes stay in localStorage for instant load, larger ones move to IndexedDB transparently, nothing ever leaves the browser. The whole thing is single-page, no signup, works offline after the first load, just a working text editor that respects the user's privacy. Useful as a reference if you're designing similar offline-first state.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;localStorage is great for: preferences, drafts, small caches, non-sensitive flags. ~10–500KB of data.&lt;/li&gt;
&lt;li&gt;Always &lt;code&gt;JSON.stringify&lt;/code&gt;/&lt;code&gt;JSON.parse&lt;/code&gt; non-string values; always &lt;code&gt;try/catch&lt;/code&gt; the parse.&lt;/li&gt;
&lt;li&gt;The 5MB limit is real and hits in production. Catch &lt;code&gt;QuotaExceededError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Synchronous writes block the main thread; debounce frequent saves.&lt;/li&gt;
&lt;li&gt;Use the &lt;code&gt;storage&lt;/code&gt; event for cross-tab sync.&lt;/li&gt;
&lt;li&gt;Version your stored data from day one — migrations cost you nothing now and save you everything later.&lt;/li&gt;
&lt;li&gt;Don't store auth tokens. Use &lt;code&gt;httpOnly&lt;/code&gt; cookies.&lt;/li&gt;
&lt;li&gt;Graduate to IndexedDB (via &lt;code&gt;idb-keyval&lt;/code&gt; or similar) for &amp;gt;5MB, querying, or binary data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;localStorage is one of those APIs that looks too simple to think about and turns out to deserve about a day of design. Spend the day; you'll save the support tickets.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>frontend</category>
    </item>
    <item>
      <title>The WiFi QR Code Format Decoded: Build One Yourself in 30 Lines</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:46:27 +0000</pubDate>
      <link>https://dev.to/helloashish99/the-wifi-qr-code-format-decoded-build-one-yourself-in-30-lines-2end</link>
      <guid>https://dev.to/helloashish99/the-wifi-qr-code-format-decoded-build-one-yourself-in-30-lines-2end</guid>
      <description>&lt;p&gt;You've scanned one a hundred times. The cafe printed a QR code, you pointed your phone at it, and you joined their WiFi without typing the 32-character password. What's actually encoded in that little black-and-white square?&lt;/p&gt;

&lt;p&gt;The answer is shorter than you'd guess. The WiFi QR format is one of the cleanest specs you'll find — six fields, plain text, no binary encoding, no cryptography. You can build a generator from scratch in thirty lines of JavaScript, which we'll do at the end of this post.&lt;/p&gt;

&lt;p&gt;But there are gotchas. Special characters in passwords break naive implementations. iOS and Android handle the same QR slightly differently. And the WPA3 transition is starting to break older codes in subtle ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format
&lt;/h2&gt;

&lt;p&gt;A WiFi QR code is just a text string in this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WIFI:T:WPA;S:NetworkName;P:password123;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire format. The QR code itself is just standard text encoding (any QR code library handles it); the magic is in the string format. Six possible fields, separated by semicolons:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Required?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Authentication type&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;S&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSID (network name)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;P&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;Required for WPA/WEP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;H&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hidden network (true/false)&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;E&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;EAP method (for enterprise)&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;I&lt;/code&gt;, &lt;code&gt;A&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Identity, anonymous identity (enterprise)&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;WIFI:&lt;/code&gt; prefix tells the scanner "this isn't a URL or plain text — treat it as a WiFi config." Two trailing semicolons close the structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auth types
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;T:&lt;/code&gt; field accepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WPA&lt;/code&gt;&lt;/strong&gt; — covers WPA, WPA2, and WPA3 (most home and business networks)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WEP&lt;/code&gt;&lt;/strong&gt; — legacy, broken since 2007, you shouldn't see it but you will&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nopass&lt;/code&gt;&lt;/strong&gt; — open networks, no password (&lt;code&gt;P&lt;/code&gt; field omitted)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no separate &lt;code&gt;WPA2&lt;/code&gt; or &lt;code&gt;WPA3&lt;/code&gt; — they all go under &lt;code&gt;WPA&lt;/code&gt;. The actual encryption negotiated is whatever the access point supports; the QR just tells the device "expect a password-protected network."&lt;/p&gt;

&lt;p&gt;Examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Open coffee-shop WiFi:
WIFI:T:nopass;S:Cafe Free WiFi;;

Standard home network:
WIFI:T:WPA;S:MyHomeWiFi;P:correcthorsebatterystaple;;

Hidden network:
WIFI:T:WPA;S:OfficeNet;P:s3cret;H:true;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The escaping rules (where naive implementations break)
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. Five characters are special and must be backslash-escaped if they appear in the SSID or password:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;\&lt;/code&gt; (backslash) — escape character itself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;;&lt;/code&gt; (semicolon) — field separator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;,&lt;/code&gt; (comma) — used in some implementations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:&lt;/code&gt; (colon) — field key/value separator&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"&lt;/code&gt; (double quote) — used to wrap special values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Common case: passwords with semicolons. &lt;code&gt;MyP;ssword&lt;/code&gt; must be encoded as &lt;code&gt;MyP\;ssword&lt;/code&gt; in the QR. Without the escape, the parser thinks the password is &lt;code&gt;MyP&lt;/code&gt; and &lt;code&gt;ssword&lt;/code&gt; is a new field.&lt;/p&gt;

&lt;p&gt;Here's a real escape function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeWifi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\\&lt;/span&gt;&lt;span class="sr"&gt;;,:"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;$&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$&amp;amp;&lt;/code&gt; in the replacement is the matched character itself, prefixed with a backslash.&lt;/p&gt;

&lt;p&gt;There's a second, weirder rule: if the SSID or password is &lt;em&gt;entirely&lt;/em&gt; a hexadecimal string (e.g., &lt;code&gt;"DEADBEEF1234"&lt;/code&gt;), it must be wrapped in double quotes to disambiguate from a hex-encoded value. This is rare in practice but spec-compliant tools do handle it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-fA-F&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escaped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;escapeWifi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;escaped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;escaped&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're testing a QR generator, try a password of &lt;code&gt;1234567890ABCDEF&lt;/code&gt; — that's the case where the quotes matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hidden networks and why they're optional-but-not
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;H:true&lt;/code&gt; field tells the device "this is a hidden network, don't expect to see it in the scan list." Without it, scanning a hidden-network QR sometimes works, sometimes doesn't, depending on whether the device can find the SSID by passive scanning.&lt;/p&gt;

&lt;p&gt;Practical advice: if you've configured your network as hidden (which I'd argue you shouldn't — it's security theater and breaks discovery), include &lt;code&gt;H:true&lt;/code&gt;. If your network is broadcasting normally, omit it. Some Android implementations behave oddly when &lt;code&gt;H:false&lt;/code&gt; is explicitly set vs omitted; safer to leave it out unless needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enterprise networks (EAP)
&lt;/h2&gt;

&lt;p&gt;For 802.1X / WPA-Enterprise networks (most large offices and universities), the format extends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WIFI:T:WPA;S:CorpNet;E:PEAP;P:password;I:username;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;E&lt;/code&gt; field specifies the EAP method (&lt;code&gt;PEAP&lt;/code&gt;, &lt;code&gt;TTLS&lt;/code&gt;, &lt;code&gt;TLS&lt;/code&gt;). The &lt;code&gt;I&lt;/code&gt; field is the user identity. Anonymous identity (&lt;code&gt;A&lt;/code&gt;) is sometimes used.&lt;/p&gt;

&lt;p&gt;In practice, this is often easier to handle through a profile (&lt;code&gt;.mobileconfig&lt;/code&gt; for iOS, &lt;code&gt;eap_config&lt;/code&gt; for Android) than through a QR code, because enterprise auth has too many knobs (CA certs, server name validation, inner auth method) to fit into a QR comfortably. Most tools that "support enterprise QR" generate something that works on the latest Android and quietly fails on iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building one in 30 lines
&lt;/h2&gt;

&lt;p&gt;Putting it all together, here's a complete generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeWifi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\\&lt;/span&gt;&lt;span class="sr"&gt;;,:"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;$&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-fA-F&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escaped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;escapeWifi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;escaped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;escaped&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildWifiString&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WPA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SSID is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nopass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Password required for &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`T:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`S:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;formatField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nopass&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`P:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;formatField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H:true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WIFI:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Convert to QR — using any QR library&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qrcode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeWifiQR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildWifiString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;QRCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;makeWifiQR&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ssid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CafeWiFi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;latte;art&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// semicolon will be properly escaped&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WPA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real, spec-compliant WiFi QR code generator. About 30 lines including the QR rendering. The &lt;code&gt;qrcode&lt;/code&gt; npm package handles the actual visual rendering; the format string is the part that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS vs Android: where they differ
&lt;/h2&gt;

&lt;p&gt;Both platforms support the format, but with subtle differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;iOS&lt;/strong&gt; (since iOS 11) reads WiFi QR codes through the Camera app — you point, a notification appears, tap to join. Works seamlessly for &lt;code&gt;WPA&lt;/code&gt; and &lt;code&gt;nopass&lt;/code&gt;. Hidden networks (&lt;code&gt;H:true&lt;/code&gt;) often don't auto-join on iOS — the network has to already be known.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Android&lt;/strong&gt; scanners vary. The native camera works on most modern Android versions. Some manufacturer skins (older Samsung, some Xiaomi) require a third-party scanner. Hidden networks generally work better on Android than iOS.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Special characters&lt;/strong&gt; sometimes survive iOS but break Android, or vice versa. Test with &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt;, and emoji-bearing SSIDs (yes, those exist) before assuming a code is universal.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reliable subset: &lt;code&gt;T:WPA&lt;/code&gt; or &lt;code&gt;T:nopass&lt;/code&gt;, ASCII passwords, broadcasting (non-hidden) networks. Stick to that and you'll have no issues across phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WPA3 transition
&lt;/h2&gt;

&lt;p&gt;WPA3 networks are starting to roll out. The QR format doesn't change — &lt;code&gt;T:WPA&lt;/code&gt; still works. But there's a new mode called "WPA3-Personal SAE" that requires the device to support SAE authentication. Older devices joining a WPA3-only network from a QR code will fail to connect even though the QR scans correctly.&lt;/p&gt;

&lt;p&gt;Most WPA3 routers are configured for "WPA2/WPA3 transition mode" by default, which means older devices fall back to WPA2. If you're building a public WiFi QR code (cafe, hotel), keep your AP in transition mode unless you have a reason not to. WPA3-only networks will silently fail for a chunk of guests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond WiFi: the format pattern
&lt;/h2&gt;

&lt;p&gt;The WiFi QR format is one of several "structured QR" specs. Others you'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vCard:&lt;/strong&gt; &lt;code&gt;BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\n...&lt;/code&gt; — for contact cards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mailto:&lt;/strong&gt; &lt;code&gt;mailto:hello@example.com?subject=Hi&amp;amp;body=...&lt;/code&gt; — for email&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tel:&lt;/strong&gt; &lt;code&gt;tel:+15555555555&lt;/code&gt; — for phone numbers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;geo:&lt;/strong&gt; &lt;code&gt;geo:37.7749,-122.4194&lt;/code&gt; — for map locations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMSTO:&lt;/strong&gt; &lt;code&gt;SMSTO:+15555555555:Hello&lt;/code&gt; — for pre-composed SMS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them are just text. The "smart" behavior happens because phone scanners recognize the prefix and offer the appropriate action.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you need to make one quickly
&lt;/h2&gt;

&lt;p&gt;For one-offs — sharing your home WiFi with a guest, posting one for an office network, generating a batch for a hotel — there's no need to write code. &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;qrtools.renderlog.in&lt;/a&gt; has a &lt;a href="https://qrtools.renderlog.in/wifi-qr-code-generator" rel="noopener noreferrer"&gt;WiFi QR code generator&lt;/a&gt; that handles the escaping correctly, plus generators for &lt;a href="https://qrtools.renderlog.in/vcard-qr-code-generator" rel="noopener noreferrer"&gt;vCard&lt;/a&gt; (contact info), &lt;a href="https://qrtools.renderlog.in/upi-qr-code-generator" rel="noopener noreferrer"&gt;UPI&lt;/a&gt; (payment QRs popular in India), and &lt;a href="https://qrtools.renderlog.in/bulk-qr-code-generator" rel="noopener noreferrer"&gt;bulk QR generation&lt;/a&gt; for if you ever need to print 200 different ones at once. Everything renders client-side, so passwords don't end up in any server log.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WiFi QR codes are just text in this format: &lt;code&gt;WIFI:T:WPA;S:name;P:password;;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Escape &lt;code&gt;\&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;,&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt; in the SSID and password — the most common bug in homemade generators.&lt;/li&gt;
&lt;li&gt;Hex-only strings should be wrapped in double quotes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;T:WPA&lt;/code&gt; covers WPA2 and WPA3; there's no separate WPA3 mode.&lt;/li&gt;
&lt;li&gt;iOS reads them via the Camera; Android via the native scanner. Both handle the basics, both have edge cases with hidden networks and special characters.&lt;/li&gt;
&lt;li&gt;The whole thing is ~30 lines of JavaScript on top of a QR library.&lt;/li&gt;
&lt;li&gt;For one-offs, &lt;a href="https://qrtools.renderlog.in/wifi-qr-code-generator" rel="noopener noreferrer"&gt;browser-based generators&lt;/a&gt; handle the escaping correctly and don't ship your password through a server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The format has lasted 15 years without a major revision, which is rare in tech. Worth understanding once, useful forever.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>networking</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Naming Things: When to Use camelCase, snake_case, kebab-case, and PascalCase</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:46:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/naming-things-when-to-use-camelcase-snakecase-kebab-case-and-pascalcase-3jjc</link>
      <guid>https://dev.to/helloashish99/naming-things-when-to-use-camelcase-snakecase-kebab-case-and-pascalcase-3jjc</guid>
      <description>&lt;p&gt;There are two hard problems in computer science: cache invalidation, naming things, and off-by-one errors. The naming-things one is the only one we can actually argue about all afternoon, so we do.&lt;/p&gt;

&lt;p&gt;This post is a practical reference for when each casing convention is correct, where they collide (databases meet APIs meet frontends meet URLs), and how to handle the conversions without sprinkling &lt;code&gt;_.camelCase()&lt;/code&gt; calls across your codebase like prayers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five conventions you'll see
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Convention&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;camelCase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;userEmail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JavaScript variables, JSON keys, Java/Swift methods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PascalCase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UserEmail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Class names, type names, React components, C# everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;snake_case&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user_email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Python variables, SQL columns, Ruby methods, env vars (sort of)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;kebab-case&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user-email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URLs, CSS classes, HTML attributes, file names, npm packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SCREAMING_SNAKE_CASE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USER_EMAIL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Constants, environment variables&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the picture. The rest of the post is about why each one ended up where it did, and what to do when you have to cross between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  camelCase — the JavaScript default
&lt;/h2&gt;

&lt;p&gt;JavaScript has used camelCase since the language existed. Variable names, function names, object keys, parameter names — all camelCase. The DOM API uses it (&lt;code&gt;document.querySelector&lt;/code&gt;, &lt;code&gt;addEventListener&lt;/code&gt;), browser globals use it, every framework follows it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jane@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java, Swift, Kotlin, and TypeScript also use camelCase for variables and methods (PascalCase for classes). If you're writing in any of these languages and the team convention is "use camelCase", that's not a stylistic preference; that's the language convention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One exception worth knowing.&lt;/strong&gt; Some JavaScript codebases use snake_case for properties that come straight from the database or from a Python/Ruby API to avoid mid-flight conversion bugs. It's a defensible choice, but it does leak the backend's convention into the frontend forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  PascalCase — for things that get instantiated
&lt;/h2&gt;

&lt;p&gt;PascalCase (which is just camelCase with the first letter capitalized) signals "this is a type, not a value." Used for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classes:&lt;/strong&gt; &lt;code&gt;class UserRepository {}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript types and interfaces:&lt;/strong&gt; &lt;code&gt;interface User {}&lt;/code&gt;, &lt;code&gt;type LoginResult = ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React components:&lt;/strong&gt; &lt;code&gt;function UserAvatar({ user })&lt;/code&gt;. JSX literally requires this — &lt;code&gt;&amp;lt;userAvatar /&amp;gt;&lt;/code&gt; is treated as an HTML element, &lt;code&gt;&amp;lt;UserAvatar /&amp;gt;&lt;/code&gt; as a component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enum values&lt;/strong&gt; in some style guides: &lt;code&gt;enum Status { Active, Pending, Banned }&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model: if you can &lt;code&gt;new&lt;/code&gt; it, type-check against it, or render it as a component, it's PascalCase. If it's a value you read or call, it's camelCase. Mostly that line is sharp; once in a while you'll see a class meant to be used like a singleton (&lt;code&gt;Math&lt;/code&gt;, &lt;code&gt;JSON&lt;/code&gt;) and the line blurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  snake_case — Python, SQL, and the systems layer
&lt;/h2&gt;

&lt;p&gt;snake_case dominates Python (&lt;code&gt;PEP 8&lt;/code&gt; mandates it), SQL columns (most style guides at least), Ruby, Rust (mostly), and any language that came out of the Unix systems-programming world.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT user_email FROM users WHERE id = %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off vs camelCase is readability of long names: &lt;code&gt;get_user_by_id&lt;/code&gt; is slightly easier to read than &lt;code&gt;getUserById&lt;/code&gt; for most people, especially with screen readers. The cost is that snake_case is one extra character per word boundary, which adds up in 50,000-line codebases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment variables are &lt;em&gt;kind of&lt;/em&gt; snake_case but uppercase:&lt;/strong&gt; &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;API_KEY&lt;/code&gt;, &lt;code&gt;NODE_ENV&lt;/code&gt;. This is convention from POSIX, and it's universal — never use lowercase or camelCase for env vars. Tools assume uppercase.&lt;/p&gt;

&lt;h2&gt;
  
  
  kebab-case — anywhere a hyphen is legal and an underscore isn't
&lt;/h2&gt;

&lt;p&gt;This is the rule. kebab-case lives wherever syntax permits hyphens but not underscores (or where hyphens are the established convention):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;URLs:&lt;/strong&gt; &lt;code&gt;/blog/why-rust-matters&lt;/code&gt;. Search engines parse hyphens as word separators; underscores get treated as part of one word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS class names:&lt;/strong&gt; &lt;code&gt;.user-avatar&lt;/code&gt;, &lt;code&gt;.is-active&lt;/code&gt;. Conventions like BEM lean hard on kebab-case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML attributes:&lt;/strong&gt; &lt;code&gt;data-user-id&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI flags:&lt;/strong&gt; &lt;code&gt;--dry-run&lt;/code&gt;, &lt;code&gt;--no-color&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm package names:&lt;/strong&gt; &lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;eslint-plugin-react&lt;/code&gt;. Underscores are technically allowed but discouraged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File names&lt;/strong&gt; in many ecosystems: &lt;code&gt;user-service.ts&lt;/code&gt;, &lt;code&gt;404-not-found.html&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why hyphens for URLs specifically? Because &lt;code&gt;/blog/why_rust_matters&lt;/code&gt; shows up in Google search ranking as one word ("why_rust_matters") rather than three. The SEO impact is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  SCREAMING_SNAKE_CASE — constants and signals
&lt;/h2&gt;

&lt;p&gt;The all-caps form is a signal that says "this value is fixed at build/deploy time, not runtime."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_RETRIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FEATURE_FLAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beta-search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new-checkout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;DEBUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JavaScript, this convention is fading — some style guides treat all &lt;code&gt;const&lt;/code&gt; as constant enough to use camelCase, reserving SCREAMING_CASE only for truly immutable, module-scoped values. Pick a rule and apply it consistently; what kills readability is a codebase where some constants are uppercase and some aren't with no rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where conventions collide
&lt;/h2&gt;

&lt;p&gt;Real applications cross between conventions constantly. The friction points:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database to API.&lt;/strong&gt; Postgres column &lt;code&gt;user_email&lt;/code&gt; becomes JSON key &lt;code&gt;userEmail&lt;/code&gt; becomes JavaScript variable &lt;code&gt;userEmail&lt;/code&gt;. Most ORMs (Sequelize, Prisma, ActiveRecord, SQLAlchemy) handle this automatically with options like &lt;code&gt;underscored: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API to URL.&lt;/strong&gt; A resource called &lt;code&gt;userProfile&lt;/code&gt; in your code lives at &lt;code&gt;/user-profile&lt;/code&gt;, not &lt;code&gt;/userprofile&lt;/code&gt; or &lt;code&gt;/userProfile&lt;/code&gt;. URL-case conversion is a 5-line function but the &lt;em&gt;boundary where you do it&lt;/em&gt; matters: at the route definition, not at the controller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File names to imports.&lt;/strong&gt; &lt;code&gt;import { UserAvatar } from './user-avatar.tsx'&lt;/code&gt;. The file is kebab-case, the export is PascalCase. Most teams hold this convention; some force file names to match their default export. Either is fine; pick one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Env to config.&lt;/strong&gt; &lt;code&gt;DATABASE_URL&lt;/code&gt; becomes &lt;code&gt;config.databaseUrl&lt;/code&gt; in code. Don't keep them named identically — that's how secret leaks happen, when someone does &lt;code&gt;console.log(config)&lt;/code&gt; and your env shows up in logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conversion is mechanical — make it boring
&lt;/h2&gt;

&lt;p&gt;The single biggest mistake teams make is converting cases by hand, in scattered places, inconsistently. Pick one of these approaches and apply it everywhere:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Convert at the boundary.&lt;/strong&gt; API response gets converted to camelCase as soon as it arrives, before any business logic touches it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;camelCase&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lodash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deepCamelCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Use a schema layer.&lt;/strong&gt; Zod, io-ts, or whatever validation library you use can transform field names as part of parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Generate the converters.&lt;/strong&gt; If your API has an OpenAPI spec, generators like &lt;code&gt;openapi-typescript-codegen&lt;/code&gt; produce camelCased clients automatically.&lt;/p&gt;

&lt;p&gt;For one-off conversions while writing or refactoring, browser tools handle the boilerplate. The text utilities at &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;text.renderlog.in&lt;/a&gt; include dedicated converters for each case style — &lt;a href="https://text.renderlog.in/camel-case-converter" rel="noopener noreferrer"&gt;camelCase&lt;/a&gt;, &lt;a href="https://text.renderlog.in/snake-case-converter" rel="noopener noreferrer"&gt;snake_case&lt;/a&gt;, &lt;a href="https://text.renderlog.in/kebab-case-converter" rel="noopener noreferrer"&gt;kebab-case&lt;/a&gt;, &lt;a href="https://text.renderlog.in/pascal-case-converter" rel="noopener noreferrer"&gt;PascalCase&lt;/a&gt;, &lt;a href="https://text.renderlog.in/title-case-converter" rel="noopener noreferrer"&gt;Title Case&lt;/a&gt;, &lt;a href="https://text.renderlog.in/sentence-case-converter" rel="noopener noreferrer"&gt;sentence case&lt;/a&gt; — useful when you're translating naming for a config schema, generating SQL DDL from a TypeScript type, or wrangling a CSV with mixed-case headers. The whole site runs client-side, so config files and column names don't get uploaded anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-patterns I see in code review
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mixed cases in one scope.&lt;/strong&gt; &lt;code&gt;const user_email&lt;/code&gt; next to &lt;code&gt;const userName&lt;/code&gt; in the same file. Pick one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stutter in identifiers.&lt;/strong&gt; &lt;code&gt;user.userEmail&lt;/code&gt;, &lt;code&gt;Order.orderId&lt;/code&gt;. The parent object already gives you the namespace; drop the prefix and write &lt;code&gt;user.email&lt;/code&gt;, &lt;code&gt;order.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acronyms inconsistently cased.&lt;/strong&gt; Is it &lt;code&gt;parseHTML&lt;/code&gt; or &lt;code&gt;parseHtml&lt;/code&gt;? &lt;code&gt;userID&lt;/code&gt; or &lt;code&gt;userId&lt;/code&gt;? Both work; what's wrong is using both in the same codebase. Most style guides land on "treat acronyms as words" — &lt;code&gt;parseHtml&lt;/code&gt;, &lt;code&gt;userId&lt;/code&gt;, &lt;code&gt;loadJsonFile&lt;/code&gt;. ESLint and most linters can enforce this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Casing that fights the language.&lt;/strong&gt; Writing &lt;code&gt;getUserById&lt;/code&gt; in Python or &lt;code&gt;get_user_by_id&lt;/code&gt; in JavaScript is a hill that's not worth dying on. Match the language's culture.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You're naming a...&lt;/th&gt;
&lt;th&gt;Use...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JS/TS variable, JSON key&lt;/td&gt;
&lt;td&gt;camelCase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class, type, React component&lt;/td&gt;
&lt;td&gt;PascalCase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python variable, SQL column&lt;/td&gt;
&lt;td&gt;snake_case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL, CSS class, file name, CLI flag&lt;/td&gt;
&lt;td&gt;kebab-case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build-time constant, env var&lt;/td&gt;
&lt;td&gt;SCREAMING_SNAKE_CASE&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pick a rule per layer, enforce it with a linter, convert at boundaries, and don't mix conventions inside one file. When you're switching between cases in your head, &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;browser-based case converters&lt;/a&gt; save the muscle memory for the actually hard problem — which is still cache invalidation.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>JSONPath Cheat Sheet: Querying Nested JSON Without Lodash</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 25 Apr 2026 13:45:55 +0000</pubDate>
      <link>https://dev.to/helloashish99/jsonpath-cheat-sheet-querying-nested-json-without-lodash-4dh1</link>
      <guid>https://dev.to/helloashish99/jsonpath-cheat-sheet-querying-nested-json-without-lodash-4dh1</guid>
      <description>&lt;p&gt;You've got a 200-line JSON response. You need the email of every user whose &lt;code&gt;lastLogin&lt;/code&gt; is older than 30 days, but only from the &lt;code&gt;team&lt;/code&gt; department, sorted by &lt;code&gt;id&lt;/code&gt;. You start writing a &lt;code&gt;.filter().map()&lt;/code&gt; chain and twenty minutes later you're debugging an &lt;code&gt;undefined&lt;/code&gt; because one user's &lt;code&gt;meta&lt;/code&gt; object is missing.&lt;/p&gt;

&lt;p&gt;This is what JSONPath was built for. It's a query language for JSON, the way XPath is for XML. You can write &lt;code&gt;$.users[?(@.lastLogin &amp;lt; @.threshold)].email&lt;/code&gt; and skip the imperative gymnastics.&lt;/p&gt;

&lt;p&gt;This is the cheat sheet I keep open while writing API tests, debugging GraphQL responses, and combing through CloudWatch logs. Real syntax, real payloads, the operators that actually show up in practice, and the gotchas that catch people who learned JSONPath from one tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payload we'll use throughout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"store"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fiction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tolkien"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Hobbit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;14.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fantasy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"classic"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fiction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Le Guin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A Wizard of Earthsea"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;12.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fantasy"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tech"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Knuth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TAOCP Vol 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;89.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"algorithms"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tech"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Crockford"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JavaScript: The Good Parts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;22.00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bicycle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"red"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;399.00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real-world enough to show off the operators, small enough to keep in your head.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core syntax (you'll use these every day)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.&lt;/code&gt; or &lt;code&gt;[]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Child accessor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;..&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Recursive descent (find anywhere)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wildcard (all children)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[n]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Array index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[start:end]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Array slice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[?(...)]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filter expression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The current node (inside a filter)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly the whole language. Everything else is composition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walking through real queries
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;All books:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[*]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[*]&lt;/code&gt; is the wildcard form. &lt;code&gt;$.store.books&lt;/code&gt; works too if you want the whole array as one result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first book's title:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[0].title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The last book's title:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[-1].title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Negative indices count from the end, like Python. Not all implementations support this — the original Goessner spec didn't, but &lt;code&gt;jsonpath-plus&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt; do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every author in the document:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$..author
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;..&lt;/code&gt; is recursive descent. It searches every depth for a key called &lt;code&gt;author&lt;/code&gt;. Useful when you don't know exactly where something lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All books with price under $20:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?(@.price &amp;lt; 20)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@&lt;/code&gt; inside the filter refers to the current item being checked. This is the operator that makes JSONPath worth learning — it does in one line what takes 5–10 lines of imperative JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Just the titles of those books:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?(@.price &amp;lt; 20)].title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filters compose with subsequent path segments. Cleaner than chaining &lt;code&gt;.filter().map()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Books that have a &lt;code&gt;tags&lt;/code&gt; field at all:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?(@.tags)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bare &lt;code&gt;@.field&lt;/code&gt; evaluates truthy if the field exists. Useful for sparse data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Books tagged 'fantasy':&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?(@.tags &amp;amp;&amp;amp; @.tags.indexOf('fantasy') &amp;gt;= 0)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or in implementations that support &lt;code&gt;in&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?('fantasy' in @.tags)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where implementations diverge. Some support &lt;code&gt;in&lt;/code&gt;, some require &lt;code&gt;indexOf&lt;/code&gt;, some have a &lt;code&gt;contains&lt;/code&gt; helper. RFC 9535 (the new official spec) standardizes a lot of this, but most libraries you'll touch still follow Goessner's original 2007 draft. Test before you commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first two books (slice):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[0:2]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same syntax as Python slicing. &lt;code&gt;[start:end:step]&lt;/code&gt; is also valid in most implementations: &lt;code&gt;$.store.books[::2]&lt;/code&gt; for every other book.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filter operators worth knowing
&lt;/h2&gt;

&lt;p&gt;Inside a &lt;code&gt;[?(...)]&lt;/code&gt;, you can use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;==&lt;/code&gt;, &lt;code&gt;!=&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;lt;=&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;gt;=&lt;/code&gt; — comparisons&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt; — logical operators&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;=~&lt;/code&gt; — regex match (in some implementations)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real example — books over $50 OR by Tolkien:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?(@.price &amp;gt; 50 || @.author == 'Tolkien')]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Author names matching a pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$.store.books[?(@.author =~ /^[A-K]/)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is library-specific. &lt;code&gt;jsonpath-plus&lt;/code&gt; supports it; the standard &lt;code&gt;jsonpath&lt;/code&gt; package on npm doesn't. If your filter doesn't work, the regex operator is the first thing I'd suspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goessner / RFC 9535 / jq divergence
&lt;/h2&gt;

&lt;p&gt;Three implementations dominate, and they disagree:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Goessner JSONPath (2007).&lt;/strong&gt; The original. Most JS libraries follow this. Filters use JavaScript-like syntax. &lt;code&gt;..&lt;/code&gt; recursive descent. No standardized output format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RFC 9535 (2024).&lt;/strong&gt; The official IETF standard. Stricter syntax, type-safe filters, no JavaScript expressions, well-defined edge cases. Adoption is still slow — most production tools haven't migrated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;jq.&lt;/strong&gt; Not actually JSONPath, but does the same job with different syntax. &lt;code&gt;.store.books[] | select(.price &amp;lt; 20)&lt;/code&gt; is the equivalent of the filter we wrote above. jq is its own world; if you're already comfortable with it, you don't need JSONPath.&lt;/p&gt;

&lt;p&gt;The practical advice: pick a JSONPath library, read its docs, don't assume queries port. The 80% that's identical is the 80% in this cheat sheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where you'll actually use JSONPath
&lt;/h2&gt;

&lt;p&gt;It's not just for browser debugging. JSONPath shows up in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postman / Insomnia / Bruno&lt;/strong&gt; — for assertions in API tests (&lt;code&gt;pm.response.json()&lt;/code&gt; returns the body, then JSONPath filters it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datadog / Grafana&lt;/strong&gt; — for extracting values from JSON log entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kubectl&lt;/strong&gt; — &lt;code&gt;kubectl get pods -o jsonpath='{.items[*].metadata.name}'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS CLI&lt;/strong&gt; — &lt;code&gt;aws ec2 describe-instances --query&lt;/code&gt;. Note: AWS uses JMESPath, not JSONPath. They look similar, they aren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser fetch hooks&lt;/strong&gt; — middleware that extracts a specific path from every API response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GraphQL response shaping&lt;/strong&gt; — though GraphQL has its own query syntax, JSONPath is useful for post-processing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The skill compounds. Learn it once, use it in five tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Forgetting &lt;code&gt;@&lt;/code&gt; inside filters.&lt;/strong&gt; &lt;code&gt;$.books[?(.price &amp;lt; 20)]&lt;/code&gt; is wrong; it should be &lt;code&gt;$.books[?(@.price &amp;lt; 20)]&lt;/code&gt;. Easy mistake when you're used to dot-prefixed paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Quoting numeric comparisons.&lt;/strong&gt; &lt;code&gt;[?(@.price &amp;lt; '20')]&lt;/code&gt; does string comparison, not numeric. The result of comparing strings to numbers is implementation-defined. Drop the quotes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Assuming results are an array.&lt;/strong&gt; A query that returns one match returns &lt;em&gt;the value&lt;/em&gt;, not a one-element array, in some libraries. Always handle both: &lt;code&gt;Array.isArray(result) ? result : [result]&lt;/code&gt;. RFC 9535 standardizes around always-an-array; legacy libraries don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Using &lt;code&gt;..&lt;/code&gt; in a hot loop.&lt;/strong&gt; Recursive descent walks the entire tree. On a 50,000-line CloudWatch payload, it's measurably slow. Prefer explicit paths when you know the structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Forgetting that filters short-circuit on missing fields.&lt;/strong&gt; &lt;code&gt;[?(@.price &amp;gt; 20)]&lt;/code&gt; excludes items where &lt;code&gt;price&lt;/code&gt; is missing. If you wanted to include them, write &lt;code&gt;[?(!@.price || @.price &amp;gt; 20)]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fastest way to test queries
&lt;/h2&gt;

&lt;p&gt;When I'm writing JSONPath against a real payload, I don't iterate in code. The feedback loop is too slow. The &lt;a href="https://json.renderlog.in/jsonpath-tester" rel="noopener noreferrer"&gt;JSONPath tester on json.renderlog.in&lt;/a&gt; lets you paste the JSON, type the query, and see results live — including syntax errors highlighted as you type. The whole thing runs client-side, so production payloads with sensitive data don't get uploaded anywhere.&lt;/p&gt;

&lt;p&gt;For one-off transformations of the JSON itself before you query it, the &lt;a href="https://json.renderlog.in/json-pretty-print" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; and &lt;a href="https://json.renderlog.in/json-validator" rel="noopener noreferrer"&gt;JSON validator&lt;/a&gt; are part of the same workflow. Bookmark all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$&lt;/code&gt; is root, &lt;code&gt;@&lt;/code&gt; is current, &lt;code&gt;..&lt;/code&gt; is recursive descent, &lt;code&gt;[?(...)]&lt;/code&gt; is a filter.&lt;/li&gt;
&lt;li&gt;80% of your queries are &lt;code&gt;$.path[?(@.field op value)].subfield&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pick one library and stick to it; queries don't always port between Goessner, RFC 9535, and jq.&lt;/li&gt;
&lt;li&gt;Test queries in a &lt;a href="https://json.renderlog.in/jsonpath-tester" rel="noopener noreferrer"&gt;browser-based JSONPath tester&lt;/a&gt; before wiring them into code.&lt;/li&gt;
&lt;li&gt;For nested-JSON debugging in general, the same sites usually have a &lt;a href="https://json.renderlog.in/json-pretty-print" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&gt; and &lt;a href="https://json.renderlog.in/compare-json" rel="noopener noreferrer"&gt;diff tool&lt;/a&gt; — bookmark them together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;JSONPath isn't elegant. It's a fragmented, half-standardized language with three competing implementations and a syntax that looks like 2007 PHP. But it solves a real problem in one line that takes ten lines of &lt;code&gt;.filter().map().reduce()&lt;/code&gt; chains, and that's worth learning.&lt;/p&gt;




&lt;p&gt;If this was useful, I've also built a handful of other free, browser-based tools — no signup, no uploads, everything runs client-side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Tools&lt;/strong&gt; — &lt;a href="https://json.renderlog.in" rel="noopener noreferrer"&gt;https://json.renderlog.in&lt;/a&gt; (formatter, validator, JWT decoder, JSONPath tester, 40+ converters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text Tools&lt;/strong&gt; — &lt;a href="https://text.renderlog.in" rel="noopener noreferrer"&gt;https://text.renderlog.in&lt;/a&gt; (case converters, slug generator, HTML/markdown utilities, 70+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF Tools&lt;/strong&gt; — &lt;a href="https://pdftools.renderlog.in" rel="noopener noreferrer"&gt;https://pdftools.renderlog.in&lt;/a&gt; (merge, split, OCR, compress to exact size, 40+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Tools&lt;/strong&gt; — &lt;a href="https://imagetools.renderlog.in" rel="noopener noreferrer"&gt;https://imagetools.renderlog.in&lt;/a&gt; (compress, convert, resize, background remover, 50+ tools)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Tools&lt;/strong&gt; — &lt;a href="https://qrtools.renderlog.in" rel="noopener noreferrer"&gt;https://qrtools.renderlog.in&lt;/a&gt; (WiFi, vCard, UPI, bulk QR codes with logos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calc Tools&lt;/strong&gt; — &lt;a href="https://calctool.renderlog.in" rel="noopener noreferrer"&gt;https://calctool.renderlog.in&lt;/a&gt; (60+ calculators for finance, health, math, dates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notepad&lt;/strong&gt; — &lt;a href="https://notepad.renderlog.in" rel="noopener noreferrer"&gt;https://notepad.renderlog.in&lt;/a&gt; (private, offline-first notes, no signup)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>json</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
