<?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>Forem: Ashish Kumar</title>
    <description>The latest articles on Forem by Ashish Kumar (@helloashish99).</description>
    <link>https://forem.com/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>Forem: Ashish Kumar</title>
      <link>https://forem.com/helloashish99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/helloashish99"/>
    <language>en</language>
    <item>
      <title>Images, Fonts, Third-Party Scripts: LCP and CLS</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 02 May 2026 00:30:00 +0000</pubDate>
      <link>https://forem.com/helloashish99/images-fonts-third-party-scripts-lcp-and-cls-idc</link>
      <guid>https://forem.com/helloashish99/images-fonts-third-party-scripts-lcp-and-cls-idc</guid>
      <description>&lt;p&gt;Images, fonts, and third-party scripts are the three categories responsible for the most field LCP and CLS regressions. They interact: a 12KB GTM tag firing synchronously in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; blocks the parser for 200ms, which means your hero image (already the LCP candidate) now loads 200ms later. LCP jumps from 1.8s to 4.2s. CrUX data reflects it three weeks later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; &lt;code&gt;fetchpriority&lt;/code&gt; and &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; for LCP images, WebP vs AVIF trade-offs, &lt;code&gt;srcset&lt;/code&gt;/&lt;code&gt;sizes&lt;/code&gt; resolution selection, &lt;code&gt;font-display&lt;/code&gt; values and what each costs, font subsetting, and the facade pattern that eliminates third-party script cost entirely until user interaction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Images and LCP: Why Your Hero Image Is Almost Always the Culprit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Largest Contentful Paint (LCP)&lt;/strong&gt; measures when the largest visible element in the viewport finishes rendering. In most marketing sites, e-commerce pages, and landing pages, that element is an image: specifically the hero image above the fold.&lt;/p&gt;

&lt;p&gt;The browser's default behavior for images is &lt;code&gt;loading="lazy"&lt;/code&gt;, which means it defers fetching images until they're near the viewport. For images below the fold, this is exactly right. For the LCP image, it is catastrophic.&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;!-- Wrong: browser waits until layout is done to decide whether to fetch --&amp;gt;&lt;/span&gt;
![Hero](https://renderlog.in/hero.jpg)

&lt;span class="c"&gt;&amp;lt;!-- Right: fetch immediately, highest network priority --&amp;gt;&lt;/span&gt;
![Hero](https://renderlog.in/hero.webp)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;fetchpriority="high"&lt;/code&gt;&lt;/strong&gt; is the piece most developers miss. Even with &lt;code&gt;loading="eager"&lt;/code&gt;, the browser's resource prioritization algorithm might assign the image a lower network priority if it's discovered late in the HTML parse: for example, if it's inside a JS-rendered component. &lt;code&gt;fetchpriority="high"&lt;/code&gt; overrides this and tells the browser: this resource competes with critical CSS, treat it accordingly.&lt;/p&gt;

&lt;p&gt;The most aggressive technique is a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; in the document head. This fires before the HTML parser even reaches the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag:&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;head&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;as=&lt;/span&gt;&lt;span class="s"&gt;"image"&lt;/span&gt;
    &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/hero.webp"&lt;/span&gt;
    &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;
    &lt;span class="na"&gt;imagesrcset=&lt;/span&gt;&lt;span class="s"&gt;"/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"&lt;/span&gt;
    &lt;span class="na"&gt;imagesizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 600px) 100vw, 1200px"&lt;/span&gt;
  &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;imagesrcset&lt;/code&gt; and &lt;code&gt;imagesizes&lt;/code&gt; attributes on the &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt;: these match your &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element's &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; so the browser can preload the correct resolution rather than a size it won't use.&lt;/p&gt;




&lt;h2&gt;
  
  
  Image Formats: WebP vs AVIF: When to Use Each
&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;Compression vs JPEG&lt;/th&gt;
&lt;th&gt;Browser Support&lt;/th&gt;
&lt;th&gt;Encoding Speed&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JPEG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Universal&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Legacy fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~30% smaller&lt;/td&gt;
&lt;td&gt;97%+ (all modern)&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;General use, default choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AVIF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~50% smaller&lt;/td&gt;
&lt;td&gt;~90% (Chrome, Firefox, Safari 16+)&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;High-quality images where encoding time is acceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PNG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lossless&lt;/td&gt;
&lt;td&gt;Universal&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Transparency, screenshots&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;WebP&lt;/strong&gt; is the pragmatic default for 2026. Browser support is effectively universal, encoding is fast enough for CI pipelines, and the ~30% size reduction over JPEG is consistent across photo content. If you're only serving one format, it should be WebP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AVIF&lt;/strong&gt; gets you another 20-25% on top of WebP for photographic content. The tradeoff is encoding time: an AVIF encode can be 10-20x slower than WebP for the same image. In practice this means pre-generating AVIF at build time or through a CDN image optimization service (Cloudinary, Imgix, Vercel Image Optimization all support it). Don't attempt AVIF on-demand at request time on a small server.&lt;/p&gt;

&lt;p&gt;Browser support for AVIF is around 90% and Safari added support in version 16. For the remaining ~10%, you always fall back with &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt;: How the Browser Picks the Right Image
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;srcset&lt;/code&gt; attribute provides a list of candidate images at different widths. The &lt;code&gt;sizes&lt;/code&gt; attribute tells the browser how wide the image will be rendered at different viewport widths. The browser uses both to pick the optimal image for the current viewport and device pixel ratio.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;![Hero](https://renderlog.in/hero-800.webp)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a 375px-wide iPhone with a 3x retina display, the browser sees: the image will render at 375px (because &lt;code&gt;max-width: 768px&lt;/code&gt; → &lt;code&gt;100vw&lt;/code&gt;), and the DPR is 3, so it needs an image that's at least 1125px wide. It picks &lt;code&gt;/hero-1200.webp&lt;/code&gt;. Without &lt;code&gt;srcset&lt;/code&gt;, it would fetch the full 2400px image: 4x more data than needed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;src&lt;/code&gt; attribute is the fallback for browsers that don't understand &lt;code&gt;srcset&lt;/code&gt;, which at this point is essentially nothing. But always include it.&lt;/p&gt;




&lt;h2&gt;
  
  
  CLS From Images: Width, Height, and the &lt;code&gt;aspect-ratio&lt;/code&gt; Trick
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cumulative Layout Shift (CLS)&lt;/strong&gt; from images has one root cause: the browser doesn't know the image dimensions before it loads, so it allocates zero height for it. When the image loads, the layout shifts down  pushing content that was already visible.&lt;/p&gt;

&lt;p&gt;The fix is declaring dimensions:&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;!-- Without dimensions: layout shift guaranteed --&amp;gt;&lt;/span&gt;
![Product photo](https://renderlog.in/photo.webp)

&lt;span class="c"&gt;&amp;lt;!-- With explicit dimensions: browser reserves space --&amp;gt;&lt;/span&gt;
![Product photo](https://renderlog.in/photo.webp)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser uses the &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes to calculate the &lt;strong&gt;intrinsic aspect ratio&lt;/strong&gt; and reserves the right amount of space before the image loads. You don't need to make the image exactly 800x600: the attributes communicate the ratio, and CSS can resize the image freely.&lt;/p&gt;

&lt;p&gt;For responsive images where you want CSS to control the rendered size:&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="nt"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&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;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* explicit fallback if width/height attributes not present */&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;aspect-ratio&lt;/code&gt; CSS property is the backup when you genuinely don't know the image dimensions ahead of time, but &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes are still the preferred mechanism because they're understood by the browser before CSS parses.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; Element: Art Direction vs Format Selection
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element serves two distinct purposes that are easy to conflate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Format selection&lt;/strong&gt;  serving different formats with a fallback:&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;
  ![Hero](https://renderlog.in/hero.jpg)
&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;The browser picks the first &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; it supports. If it supports AVIF, it uses that. Otherwise WebP. Otherwise JPEG. The &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag is always required as the final fallback and carries the &lt;code&gt;alt&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, and any loading attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Art direction&lt;/strong&gt;  serving different crops for different viewports:&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;media=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 768px)"&lt;/span&gt;
    &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"/hero-portrait-400.webp 400w, /hero-portrait-800.webp 800w"&lt;/span&gt;
    &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"100vw"&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-landscape-800.webp 800w, /hero-landscape-1200.webp 1200w"&lt;/span&gt;
    &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 1200px) 50vw, 1200px"&lt;/span&gt;
  &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  ![Hero](https://renderlog.in/hero-landscape-800.webp)
&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;On mobile, you serve a portrait-cropped version where the subject fills the frame. On desktop, the wide landscape version. This is different from responsive sizing: it's serving a different image, not a different resolution of the same image.&lt;/p&gt;

&lt;p&gt;You can combine both: use &lt;code&gt;media&lt;/code&gt; for art direction and &lt;code&gt;type&lt;/code&gt; for format selection within the same &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element.&lt;/p&gt;




&lt;h2&gt;
  
  
  Font Loading and CLS: The FOUT/FOIT Trade-off
&lt;/h2&gt;

&lt;p&gt;Web fonts are one of the most misunderstood sources of CLS and &lt;strong&gt;layout instability&lt;/strong&gt;. There are two phenomena:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FOIT (Flash of Invisible Text)&lt;/strong&gt;  text is invisible until the font loads. The browser reserves the space but shows nothing. CLS score is zero, but users see invisible content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FOUT (Flash of Unstyled Text)&lt;/strong&gt;  text renders immediately in a fallback font, then swaps to the web font when it loads. Users see content immediately, but the font swap can cause a layout shift if the metric sizes differ.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;font-display&lt;/code&gt; descriptor controls this behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;font-display&lt;/code&gt; value&lt;/th&gt;
&lt;th&gt;Block period&lt;/th&gt;
&lt;th&gt;Swap period&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser decides&lt;/td&gt;
&lt;td&gt;Browser decides&lt;/td&gt;
&lt;td&gt;Don't use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Icon fonts that must not show fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;swap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Body text  always shows text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fallback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~100ms&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;td&gt;Balance of FOUT and FOIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;optional&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~100ms&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Non-essential decorative fonts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For body text, &lt;code&gt;font-display: swap&lt;/code&gt; is the right call: users see text immediately, and the font swap happens quickly enough that most users don't perceive it. For hero/headline fonts where the metrics difference between your web font and system font would cause a large CLS, &lt;code&gt;font-display: fallback&lt;/code&gt; is better  it gives the font a short window to load before swapping, and if it doesn't arrive in time, it stays on the fallback for that page view.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;font-display: optional&lt;/code&gt; is underused. It tells the browser: if the font is already cached, use it; otherwise, don't bother for this page view. For non-critical decorative fonts, this is ideal: no CLS, no FOIT, and cached users get the full experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;preconnect&lt;/code&gt; and &lt;code&gt;preload&lt;/code&gt; for Fonts
&lt;/h2&gt;

&lt;p&gt;If you're loading fonts from Google Fonts or another CDN, &lt;code&gt;preconnect&lt;/code&gt; eliminates the DNS + TCP + TLS handshake latency:&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;head&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- For Google Fonts: preconnect to both origins --&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://fonts.googleapis.com"&lt;/span&gt; &lt;span class="nt"&gt;/&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://fonts.gstatic.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- The actual font stylesheet --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt;
    &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&amp;amp;display=swap"&lt;/span&gt;
    &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt;
  &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;crossorigin&lt;/code&gt; attribute on the second &lt;code&gt;preconnect&lt;/code&gt; is critical: font files are fetched with CORS headers, so the connection must also be established as a CORS connection. Missing this means the preconnect establishes a non-CORS connection that can't be reused for the actual font fetch.&lt;/p&gt;

&lt;p&gt;For self-hosted fonts, &lt;code&gt;preload&lt;/code&gt; is more aggressive:&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;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-v13-latin-regular.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;type=&lt;/span&gt;&lt;span class="s"&gt;"font/woff2"&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;p&gt;Preload the specific font file that renders above-fold text, usually the regular weight of your body font. Preloading every weight and style defeats the purpose and wastes bandwidth on weights that may not even render on the current page.&lt;/p&gt;




&lt;h2&gt;
  
  
  System Font Stacks: The Right Answer More Often Than You Think
&lt;/h2&gt;

&lt;p&gt;Before reaching for a web font, ask whether a system font stack meets the design requirement. In 2026, &lt;code&gt;-apple-system&lt;/code&gt;, &lt;code&gt;Segoe UI&lt;/code&gt;, and Roboto are genuinely good fonts that ship with the OS:&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="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;-apple-system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;BlinkMacSystemFont&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;"Segoe UI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Roboto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Oxygen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Ubuntu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;sans-serif&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;No font file downloads. No FOUT. No CLS. Zero font-related LCP impact. For internal tools, dashboards, documentation sites, and developer-facing products, system fonts are often indistinguishable from a web font in practice and eliminate an entire category of performance problems.&lt;/p&gt;

&lt;p&gt;If your brand absolutely requires a specific typeface, then use web fonts. But default to system fonts and add custom fonts when there's a genuine design need, not as a reflexive choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Subsetting Fonts: Removing Glyphs You Don't Need
&lt;/h2&gt;

&lt;p&gt;A full-weight Inter or Roboto font file can be 150-300KB. The Latin subset you actually render on an English-language site uses maybe 250 of those glyphs. &lt;strong&gt;Subsetting&lt;/strong&gt; removes the unused glyphs and can reduce font size by 60-80%.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;pyftsubset&lt;/code&gt; from the &lt;code&gt;fonttools&lt;/code&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;fonttools brotli

&lt;span class="c"&gt;# Subset to Latin characters only&lt;/span&gt;
pyftsubset inter-regular.woff2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;inter-regular-latin.woff2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flavor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;woff2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unicodes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google Fonts does this for you automatically when you include &lt;code&gt;&amp;amp;subset=latin&lt;/code&gt; in the URL: it's one of the real performance benefits of using their CDN. If you're self-hosting, run subsetting as part of your build pipeline.&lt;/p&gt;

&lt;p&gt;For sites with only ASCII content, you can subset even more aggressively, often getting font files down to 15-25KB.&lt;/p&gt;




&lt;h2&gt;
  
  
  Third-Party Scripts: How They Block the Main Thread
&lt;/h2&gt;

&lt;p&gt;Every third-party script you add to a page comes with a real performance cost. The &lt;strong&gt;third-party-web dataset&lt;/strong&gt; (maintained by Patrick Hulce) aggregates real-world data on how long third-party scripts block the main thread. Some numbers from the dataset:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Third Party&lt;/th&gt;
&lt;th&gt;Median Blocking Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google Tag Manager&lt;/td&gt;
&lt;td&gt;~70ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intercom chat widget&lt;/td&gt;
&lt;td&gt;~130ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hotjar&lt;/td&gt;
&lt;td&gt;~90ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook Pixel&lt;/td&gt;
&lt;td&gt;~50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift chat&lt;/td&gt;
&lt;td&gt;~140ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are medians: individual sites see much worse. And these costs are additive. If you add GTM + Intercom + Hotjar, you're looking at 300ms of main thread blocking before the user can interact with anything.&lt;/p&gt;

&lt;p&gt;The problem is compounded by Tag Manager: GTM itself blocks for 70ms, then it fires &lt;em&gt;additional&lt;/em&gt; tags that each have their own cost. A poorly governed GTM container can fire 15-20 tags synchronously, each making additional network requests, each running arbitrary JavaScript.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deferring Third-Party Scripts
&lt;/h2&gt;

&lt;p&gt;The simplest fix for most third-party scripts is using &lt;code&gt;async&lt;/code&gt; or &lt;code&gt;defer&lt;/code&gt;:&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;!-- Blocks HTML parsing: never do this for third parties --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.example.com/script.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Downloads in parallel, executes after parse: use this --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.example.com/script.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Downloads in parallel, executes after DOM is ready: use this for non-critical --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.example.com/script.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For GTM specifically, the container snippet they provide uses &lt;code&gt;async&lt;/code&gt; by default, but the tags fired &lt;em&gt;inside&lt;/em&gt; GTM often don't. The real lever is auditing your GTM container and ensuring tags fire on events (scroll, interaction, &lt;code&gt;DOMContentLoaded&lt;/code&gt;) rather than firing immediately on page load.&lt;/p&gt;

&lt;p&gt;Moving analytics to server-side is the most impactful option for privacy-focused teams and those who need accurate data without affecting client performance. Server-side GTM routes events through your own server rather than sending data client-side. Setup is more complex but eliminates the client-side script entirely for analytics.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Facade Pattern for Heavy Embeds
&lt;/h2&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%2Fb9705s4tis4vo152nly0.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%2Fb9705s4tis4vo152nly0.png" alt="Diagram of the facade pattern for third-party embeds: lightweight placeholder until user interaction loads the full widget." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Facades&lt;/strong&gt; are lightweight placeholders that replace heavy third-party embeds until user interaction. The most common example is YouTube embeds:&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;// Heavy: embeds the full YouTube player iframe on page load (~500KB)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://www.youtube.com/embed/dQw4w9WgXcQ"&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"560"&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"315"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Facade: shows a thumbnail and play button, loads the real embed on click&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;YouTubeFacade&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoaded&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loaded&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;iframe&lt;/span&gt;
        &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`https://www.youtube.com/embed/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;videoId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?autoplay=1`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"560"&lt;/span&gt;
        &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"315"&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"autoplay"&lt;/span&gt;
      &lt;span class="p"&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&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="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relative&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pointer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;aspectRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;16/9&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setLoaded&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="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      ![](https://renderlog.in)
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;absolute&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;50%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;50%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;translate(-50%, -50%)&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="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`Play &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        ▶
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;lite-youtube-embed&lt;/code&gt; web component is a production-ready version of this pattern: a single small script that renders a YouTube thumbnail and loads the real embed on click. It saves ~500KB of JavaScript that would otherwise execute on page load.&lt;/p&gt;

&lt;p&gt;The same pattern applies to Google Maps (use a static map image as placeholder), Intercom and Drift (use a custom "Chat" button that loads the SDK on click), and Calendly embeds (open in a modal on demand).&lt;/p&gt;

&lt;p&gt;Lighthouse's &lt;strong&gt;Facade&lt;/strong&gt; audit specifically flags third-party embeds that have known lightweight alternatives and quantifies the time savings. It's one of the most actionable audits in the report.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting It Together: A Pre-Launch Checklist
&lt;/h2&gt;

&lt;p&gt;Every page I ship now goes through a quick mental checklist before it hits production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LCP image has &lt;code&gt;loading="eager"&lt;/code&gt;, &lt;code&gt;fetchpriority="high"&lt;/code&gt;, explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;, and is preloaded in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;LCP image is served in WebP (AVIF if the CDN supports it), with JPEG fallback via &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;All images have &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; for responsive resolution selection&lt;/li&gt;
&lt;li&gt;No image is missing &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes&lt;/li&gt;
&lt;li&gt;Web fonts use &lt;code&gt;font-display: swap&lt;/code&gt; or &lt;code&gt;fallback&lt;/code&gt;; &lt;code&gt;preconnect&lt;/code&gt; to font CDN is in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Font files are subsetted to the character ranges actually used&lt;/li&gt;
&lt;li&gt;GTM container is audited; no tags fire synchronously on page load&lt;/li&gt;
&lt;li&gt;YouTube/maps/chat embeds use a facade&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;third-party-web&lt;/code&gt; data checked for any new scripts added in the last sprint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The facade pattern, combined with deferring GTM tags to &lt;code&gt;DOMContentLoaded&lt;/code&gt;, is consistently the highest-leverage fix for third-party script LCP impact: the marketing team's tags still fire correctly, just after the user has already seen the page.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/images-fonts-third-party-performance/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/images-fonts-third-party-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>images</category>
      <category>fonts</category>
      <category>lcp</category>
    </item>
    <item>
      <title>Tree Shaking and Code Splitting in JavaScript</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Fri, 01 May 2026 00:30:00 +0000</pubDate>
      <link>https://forem.com/helloashish99/tree-shaking-and-code-splitting-in-javascript-gkd</link>
      <guid>https://forem.com/helloashish99/tree-shaking-and-code-splitting-in-javascript-gkd</guid>
      <description>&lt;p&gt;A 2MB gzipped JavaScript bundle is ~7MB for V8 to parse and compile. On a mid-range Android at 3G, that is 12 seconds before a single interaction is possible. Bundle size is not an abstract metric — it is directly proportional to Time to Interactive on real hardware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why bundle bloat happens silently:&lt;/strong&gt; Every convenient &lt;code&gt;import&lt;/code&gt; adds to the module graph. &lt;code&gt;moment.js&lt;/code&gt; locale files, full &lt;code&gt;lodash&lt;/code&gt; imports, icon libraries with 1000 icons — none of these produce visible errors. They just make the app slower on devices you don't test on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; How bundlers construct the module graph, where tree shaking fails silently, how &lt;code&gt;sideEffects&lt;/code&gt; in package.json controls elimination, and how dynamic &lt;code&gt;import()&lt;/code&gt; splits the bundle into chunks that load on demand.&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%2Fk4x9n7hh0aj8qymzww4w.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%2Fk4x9n7hh0aj8qymzww4w.png" alt="Diagram of a bundler pipeline: entry point → module graph → tree shaking → code splitting → output chunks." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What a bundle actually is
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;npm run build&lt;/code&gt;, your bundler (Rollup inside Vite, Webpack, esbuild) does roughly this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Starting from your entry point (&lt;code&gt;main.tsx&lt;/code&gt;), it follows every &lt;code&gt;import&lt;/code&gt; statement&lt;/li&gt;
&lt;li&gt;It builds a &lt;strong&gt;module graph&lt;/strong&gt;  a directed graph of every file that's reachable from the entry&lt;/li&gt;
&lt;li&gt;It merges all those files into one or more output files, resolving module boundaries&lt;/li&gt;
&lt;li&gt;It applies &lt;strong&gt;tree shaking&lt;/strong&gt; to remove code that's imported but never called&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;minifies&lt;/strong&gt; (removes whitespace and shortens names) and optionally compresses&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The default result is &lt;strong&gt;one file&lt;/strong&gt; containing your entire application: every component, every utility, every vendor library. That single file must be downloaded, parsed, and compiled before anything runs.&lt;/p&gt;

&lt;p&gt;The reason everything ends up in one file by default is &lt;strong&gt;performance optimization for the common case&lt;/strong&gt;: one large file is often faster than dozens of small ones, because each file requires a separate HTTP request (with its own connection overhead), and the browser's HTTP cache is most effective when file names are stable. One bundle = one cache entry = minimal request overhead.&lt;/p&gt;

&lt;p&gt;But "one big file" becomes a liability when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The bundle contains code for routes the user will never visit&lt;/li&gt;
&lt;li&gt;It contains multiple large vendor libraries that could be split into separate caches&lt;/li&gt;
&lt;li&gt;Any change to the app (even one line) invalidates the entire bundle cache&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why code splitting matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bundle analysis: seeing what's actually in there
&lt;/h2&gt;

&lt;p&gt;Before optimizing, you need to understand the bundle's contents. Three tools I actually use:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;rollup-plugin-visualizer&lt;/strong&gt; (for Vite/Rollup projects) generates an interactive treemap:&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;// vite.config.ts&lt;/span&gt;

  &lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;visualizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bundle-stats.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;gzipSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;brotliSize&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;npm run build&lt;/code&gt;, open &lt;code&gt;bundle-stats.html&lt;/code&gt;. You see a rectangle-packed treemap where each rectangle's area is proportional to the module's size in the bundle. Scan for large vendor rectangles that surprise you. A common discovery is that &lt;code&gt;moment.js&lt;/code&gt; is taking 300KB because someone imported it for date formatting once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;webpack-bundle-analyzer&lt;/strong&gt; does the same for Webpack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; webpack-bundle-analyzer
npx webpack &lt;span class="nt"&gt;--profile&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; stats.json
npx webpack-bundle-analyzer stats.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;source-map-explorer&lt;/strong&gt; is useful when you have source maps but not a plugin-compatible build setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx source-map-explorer dist/assets/index-abc123.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to look for when reading a bundle visualization:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unexpectedly large single modules&lt;/strong&gt;  often a library you didn't know was that big&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate packages&lt;/strong&gt;  the same library appearing twice at different versions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code you thought was excluded&lt;/strong&gt;  development utilities or storybook code in production builds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polyfills for modern browsers&lt;/strong&gt;  if your target browsers support native features, polyfills are dead weight&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Tree shaking: how it works and why it fails silently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tree shaking&lt;/strong&gt; is the process of eliminating code from the bundle that is imported but never actually called. The term comes from "shaking a tree and letting the dead leaves fall."&lt;/p&gt;

&lt;p&gt;Tree shaking relies on &lt;strong&gt;ES module static analysis&lt;/strong&gt;. The key insight: &lt;code&gt;import&lt;/code&gt; and &lt;code&gt;export&lt;/code&gt; statements are &lt;strong&gt;static&lt;/strong&gt;: the module graph is fully knowable at build time without executing any code.&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;// ES Module: statically analyzable&lt;/span&gt;

&lt;span class="c1"&gt;// Bundler knows: only formatDate and parseDate are used.&lt;/span&gt;
&lt;span class="c1"&gt;// Any other exports from dates.js can be eliminated.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare to &lt;strong&gt;CommonJS&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;// CommonJS: NOT statically analyzable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./dates.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Bundler cannot know which properties of 'dates' are used at build time.&lt;/span&gt;
&lt;span class="c1"&gt;// require() could be called conditionally, so the entire module must be included.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why mixing CommonJS and ES modules causes tree shaking to silently fail. The bundler falls back to including the entire module.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;sideEffects&lt;/code&gt; field
&lt;/h2&gt;

&lt;p&gt;Even with ES modules, tree shaking requires one more thing: the &lt;code&gt;sideEffects&lt;/code&gt; field in &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;side effect&lt;/strong&gt; is code that runs when a module is imported, regardless of whether you use its exports. CSS imports, polyfills, global registrations: these are side effects. If the bundler assumes a module has side effects, it can't eliminate it even if none of its exports are used.&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;package.json&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;"my-library"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sideEffects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;&lt;code&gt;"sideEffects": false&lt;/code&gt; tells bundlers: "every file in this package is pure. If nothing imports from it, it can be eliminated." Without this, even unused modules from a dependency are included in the bundle.&lt;/p&gt;

&lt;p&gt;For your own application code, you can be more precise:&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;"sideEffects"&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;"*.css"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/polyfills.js"&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;This says: CSS files and the polyfills file have side effects (they must be included), but all other JavaScript files are pure. The bundler can tree-shake the JavaScript while preserving the CSS imports.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common tree shaking failures
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Barrel files
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;barrel file&lt;/strong&gt; is an &lt;code&gt;index.js&lt;/code&gt; that re-exports everything from a directory:&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;// components/index.js (barrel file)&lt;/span&gt;

&lt;span class="c1"&gt;// ... 50 more exports&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;// Using it&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: some bundlers (particularly older Webpack configurations and certain Rollup setups) cannot tree-shake barrel files reliably. When you import &lt;code&gt;Button&lt;/code&gt; from the barrel, the bundler may include the entire barrel  all 50+ components  because it can't prove the barrel itself doesn't have side effects from the re-export pattern.&lt;/p&gt;

&lt;p&gt;The fix is to import directly from the source file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or configure your bundler explicitly to handle barrels. Vite handles this well in modern versions, but it's worth verifying with the bundle analyzer that barrel imports aren't inflating your output.&lt;/p&gt;

&lt;h3&gt;
  
  
  lodash
&lt;/h3&gt;

&lt;p&gt;Importing lodash like this includes the entire 70KB library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is either &lt;code&gt;lodash-es&lt;/code&gt; (which uses ES modules and tree-shakes correctly) or direct cherry-picking:&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;// or&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  moment.js locale problem
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;moment.js&lt;/code&gt; has a notorious bundling issue: it includes all locale files by default. If you're using moment at all, you're pulling in ~300KB of locale data for languages you'll never use.&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;// This pulls in ALL locales: ~300KB gzipped&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The options, ranked by recommendation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Replace with &lt;code&gt;date-fns&lt;/code&gt;&lt;/strong&gt;  tree-shakeable, only includes what you import&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace with &lt;code&gt;dayjs&lt;/code&gt;&lt;/strong&gt;  2KB gzipped, locale files are separate optional imports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Webpack IgnorePlugin&lt;/strong&gt; to exclude locale files from moment (if migration is not feasible)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Code splitting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Code splitting&lt;/strong&gt; is the practice of splitting your bundle into multiple files that load on demand. Instead of one 2MB bundle, you might have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 200KB core bundle that loads immediately&lt;/li&gt;
&lt;li&gt;A 400KB dashboard chunk that loads when the user navigates to &lt;code&gt;/dashboard&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A 100KB admin chunk that loads only if the user is an admin&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: users only download code for the features they actually use.&lt;/p&gt;

&lt;h3&gt;
  
  
  React.lazy() and Suspense
&lt;/h3&gt;

&lt;p&gt;The canonical React code splitting pattern:&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;// Instead of: import Dashboard from './Dashboard';&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Dashboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AdminPanel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./AdminPanel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&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="o"&gt;&amp;gt;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;React.lazy()&lt;/code&gt; takes a function that returns a dynamic &lt;code&gt;import()&lt;/code&gt;. The bundler sees the dynamic import and automatically creates a separate chunk for &lt;code&gt;Dashboard.tsx&lt;/code&gt; and all its unique dependencies. That chunk is not downloaded until the user navigates to &lt;code&gt;/dashboard&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Suspense&lt;/code&gt; boundary shows `` while the chunk is downloading. For route-level splits, this is typically the loading spinner or skeleton you'd show during data fetching anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic import() and chunk naming
&lt;/h3&gt;

&lt;p&gt;Under the hood, &lt;code&gt;lazy()&lt;/code&gt; uses dynamic &lt;code&gt;import()&lt;/code&gt;. You can use it directly for non-component code:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
// Loads only when called, not at app startup&lt;br&gt;
async function processSpreadsheet(file) {&lt;br&gt;
  const { read, utils } = await import('xlsx'); // 400KB library, only loaded when needed&lt;br&gt;
  const workbook = read(await file.arrayBuffer());&lt;br&gt;
  return utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);&lt;br&gt;
}&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic comments&lt;/strong&gt; let you control chunk names:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
const Dashboard = lazy(() =&amp;gt; import(&lt;br&gt;
  /* webpackChunkName: "dashboard" */&lt;br&gt;
  /* vite: { chunkName: "dashboard" } */&lt;br&gt;
  './Dashboard'&lt;br&gt;
));&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Without magic comments, bundlers generate hash-based names like &lt;code&gt;chunk-abc123.js&lt;/code&gt;. Named chunks make your build output readable and help with debugging production issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vendor splitting for cache utilization
&lt;/h2&gt;

&lt;p&gt;Libraries like React, React DOM, and React Router change infrequently, maybe once every few months when you upgrade. Your application code changes with every deployment.&lt;/p&gt;

&lt;p&gt;If they're all in one bundle, every deployment invalidates the browser's cache for React  even though React itself hasn't changed.&lt;/p&gt;

&lt;p&gt;Vendor splitting keeps stable libraries in separate chunks with content-hash filenames:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;js&lt;br&gt;
// vite.config.ts&lt;/p&gt;

&lt;p&gt;build: {&lt;br&gt;
    rollupOptions: {&lt;br&gt;
      output: {&lt;br&gt;
        manualChunks: {&lt;br&gt;
          'vendor-react': ['react', 'react-dom', 'react-router-dom'],&lt;br&gt;
          'vendor-query': ['@tanstack/react-query'],&lt;br&gt;
          'vendor-ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Now &lt;code&gt;vendor-react-[hash].js&lt;/code&gt; stays cached across deployments. Users who visited yesterday already have React cached. They only download your application code when you deploy.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bundle type&lt;/th&gt;
&lt;th&gt;Changes on every deploy?&lt;/th&gt;
&lt;th&gt;Cache lifetime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App code&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Short (invalidated on every deploy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor (React etc.)&lt;/td&gt;
&lt;td&gt;No (until you upgrade)&lt;/td&gt;
&lt;td&gt;Long (months between upgrades)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature chunks&lt;/td&gt;
&lt;td&gt;Only when that feature changes&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The entry point waterfall
&lt;/h2&gt;

&lt;p&gt;One anti-pattern that's easy to overlook: &lt;strong&gt;eagerly importing all routes in the entry point&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;js&lt;br&gt;
// main.tsx (WRONG): eager imports defeat code splitting&lt;/p&gt;

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

&lt;p&gt;Even if you use React Router to only render one route at a time, Webpack/Vite will see these static imports and include all pages in the initial bundle. The dynamic import pattern only works when &lt;code&gt;import()&lt;/code&gt; is actually dynamic:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
// main.tsx (RIGHT): all routes are lazily loaded&lt;br&gt;
const Dashboard = lazy(() =&amp;gt; import('./pages/Dashboard'));&lt;br&gt;
const Settings = lazy(() =&amp;gt; import('./pages/Settings'));&lt;br&gt;
const Admin = lazy(() =&amp;gt; import('./pages/Admin'));&lt;br&gt;
const Reports = lazy(() =&amp;gt; import('./pages/Reports'));&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Check your bundle visualizer. If all your route components appear in the main chunk, this is why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring bundle impact in CI
&lt;/h2&gt;

&lt;p&gt;Ad-hoc bundle analysis after the fact is better than nothing, but the real win is &lt;strong&gt;preventing bundle bloat in CI&lt;/strong&gt; before it reaches production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;size-limit&lt;/code&gt;&lt;/strong&gt; is a package that fails your CI build if the bundle exceeds defined limits:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`json&lt;br&gt;
// package.json&lt;br&gt;
{&lt;br&gt;
  "size-limit": [&lt;br&gt;
    {&lt;br&gt;
      "path": "dist/assets/index-*.js",&lt;br&gt;
      "limit": "300 KB",&lt;br&gt;
      "gzip": true&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "path": "dist/assets/vendor-react-*.js",&lt;br&gt;
      "limit": "150 KB",&lt;br&gt;
      "gzip": true&lt;br&gt;
    }&lt;br&gt;
  ]&lt;br&gt;
}&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

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

&lt;h1&gt;
  
  
  .github/workflows/build.yml
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;name: Check bundle size
run: npx size-limit
&lt;code&gt;&lt;/code&gt;`&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With this in place, a PR that inadvertently adds a 500KB dependency (say, someone imports &lt;code&gt;moment&lt;/code&gt; instead of &lt;code&gt;date-fns&lt;/code&gt;) will fail CI with a clear error message:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;plaintext&lt;br&gt;
  dist/assets/index-abc123.js&lt;br&gt;
  Size: 487 KB with all dependencies, minified and gzipped&lt;/p&gt;

&lt;p&gt;Package size limit has exceeded the limit.&lt;br&gt;
  Size limit: 300 KB&lt;br&gt;
  Size: 487 KB&lt;br&gt;
  Try to reduce size or increase the limit.&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance budgets&lt;/strong&gt; go beyond just JS size. Lighthouse CI lets you set budgets on LCP, TTI, and total transfer size:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`json&lt;br&gt;
// lighthouserc.js&lt;br&gt;
module.exports = {&lt;br&gt;
  assert: {&lt;br&gt;
    assertions: {&lt;br&gt;
      'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],&lt;br&gt;
      'interactive': ['error', { maxNumericValue: 5000 }],&lt;br&gt;
      'total-byte-weight': ['error', { maxNumericValue: 1000000 }],&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
};&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The bundle optimization checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Expected win&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Analyze current bundle&lt;/td&gt;
&lt;td&gt;rollup-plugin-visualizer&lt;/td&gt;
&lt;td&gt;Identify large dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find duplicate packages&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm dedupe&lt;/code&gt;, &lt;code&gt;bundlesize&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Eliminate redundant copies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix lodash imports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lodash-es&lt;/code&gt; or cherry-pick&lt;/td&gt;
&lt;td&gt;Save 50–70KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add &lt;code&gt;sideEffects: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;package.json&lt;/td&gt;
&lt;td&gt;Enable tree shaking for your code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route-level code splitting&lt;/td&gt;
&lt;td&gt;&lt;code&gt;React.lazy()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defer non-critical routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor splitting&lt;/td&gt;
&lt;td&gt;Rollup &lt;code&gt;manualChunks&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Improve cache utilization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic import for heavy libs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;import()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only load when needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set size limits in CI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;size-limit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prevent future regressions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bundles don't bloat all at once. They bloat incrementally — one convenient import at a time. The defense is measurement, budgets, and a clear understanding of what each dependency actually costs to download, parse, and compile on real hardware.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/build-bundles-treeshaking-code-splitting/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/build-bundles-treeshaking-code-splitting/&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>bundling</category>
      <category>javascript</category>
      <category>codesplitting</category>
    </item>
    <item>
      <title>Web Workers in React: Heavy Work Off Main Thread</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Thu, 30 Apr 2026 00:30:00 +0000</pubDate>
      <link>https://forem.com/helloashish99/web-workers-in-react-heavy-work-off-main-thread-5ii</link>
      <guid>https://forem.com/helloashish99/web-workers-in-react-heavy-work-off-main-thread-5ii</guid>
      <description>&lt;p&gt;Heavy CPU work — parsing large binary files, sorting 50,000 records, running ML inference — belongs on a separate thread, not the main thread. The main thread has one job: keep the UI responsive. Every millisecond it spends on computation: a millisecond it cannot spend processing input events or painting frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Web Workers give you:&lt;/strong&gt; JavaScript running in a separate OS thread, completely isolated from the main thread's event loop. While the worker processes data, the main thread handles clicks, renders frames, and runs React's reconciler, unblocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; The Worker communication model and structured clone limits, when Workers help vs when they don't, Comlink for ergonomic async APIs: the React custom hook pattern for Worker lifecycle management, Worker pools, and OffscreenCanvas (rendering off-thread).&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%2Fa9rl3k18rr8zbefq55ku.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%2Fa9rl3k18rr8zbefq55ku.png" alt="Architecture diagram of Web Workers: main thread UI and event loop stays responsive while a worker runs heavy computation off-thread." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What Web Workers actually are
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Web Worker&lt;/strong&gt; is JavaScript running in a separate OS thread from the browser's main thread. It has its own global scope (&lt;code&gt;self&lt;/code&gt; instead of &lt;code&gt;window&lt;/code&gt;), its own event loop, and cannot directly access the DOM, &lt;code&gt;document&lt;/code&gt;, &lt;code&gt;window&lt;/code&gt;, or any browser APIs that require the main thread.&lt;/p&gt;

&lt;p&gt;The communication model is &lt;strong&gt;message passing&lt;/strong&gt;: the main thread and the worker communicate by calling &lt;code&gt;postMessage()&lt;/code&gt; and listening to &lt;code&gt;message&lt;/code&gt; events. The data passed between them is &lt;strong&gt;structured cloned&lt;/strong&gt;  serialized into a binary format and deserialized on the other side. No shared memory by default.&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;// main.js&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./my-worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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;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="s2"&gt;PARSE&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="nx"&gt;rawData&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;setProcessedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// my-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="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="s2"&gt;message&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="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="s2"&gt;PARSE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;heavyParseFunction&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="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="nx"&gt;result&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 worker runs &lt;code&gt;heavyParseFunction&lt;/code&gt; in its own thread. The main thread is free to handle user events, paint frames, and run React's reconciler while the work happens.&lt;/p&gt;




&lt;h2&gt;
  
  
  The structured clone algorithm: what can cross the boundary
&lt;/h2&gt;

&lt;p&gt;Not everything can be passed to a Worker via &lt;code&gt;postMessage&lt;/code&gt;. The &lt;strong&gt;structured clone algorithm&lt;/strong&gt; defines what's serializable. Understanding its limits saves debugging time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What can be cloned:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Primitives (&lt;code&gt;string&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt;, &lt;code&gt;BigInt&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Arrays and plain objects (deeply nested, recursively)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt;, &lt;code&gt;RegExp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ArrayBuffer&lt;/code&gt;, &lt;code&gt;TypedArray&lt;/code&gt; (&lt;code&gt;Uint8Array&lt;/code&gt;, &lt;code&gt;Float32Array&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Blob&lt;/code&gt;, &lt;code&gt;File&lt;/code&gt;, &lt;code&gt;ImageData&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MessagePort&lt;/code&gt; (via transfer, not clone)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What cannot be cloned:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Functions (cannot be serialized  this is a fundamental limit)&lt;/li&gt;
&lt;li&gt;DOM nodes or element references&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Proxy&lt;/code&gt; objects&lt;/li&gt;
&lt;li&gt;Objects with circular references (will throw)&lt;/li&gt;
&lt;li&gt;Class instances with custom prototypes (only own enumerable properties are cloned, prototype chain is lost)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This will throw  functions can't be cloned&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;callback&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hi&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;// DataCloneError&lt;/span&gt;

&lt;span class="c1"&gt;// This works  plain data&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Transferable objects&lt;/strong&gt; are a different mechanism: instead of cloning data, you &lt;em&gt;transfer&lt;/em&gt; ownership. The sending side loses access; the receiver gains it. This is zero-copy and critical for large buffers.&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;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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 10MB&lt;/span&gt;
&lt;span class="c1"&gt;// Transfer the buffer  main thread can no longer access it&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ArrayBuffer&lt;/code&gt;, &lt;code&gt;MessagePort&lt;/code&gt;, &lt;code&gt;OffscreenCanvas&lt;/code&gt;, &lt;code&gt;ImageBitmap&lt;/code&gt;, and &lt;code&gt;ReadableStream&lt;/code&gt; are all transferable.&lt;/p&gt;




&lt;h2&gt;
  
  
  CPU-bound vs I/O-bound: the right use cases
&lt;/h2&gt;

&lt;p&gt;The mistake I see most often is reaching for Workers for the wrong kind of work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Workers help with CPU-bound tasks&lt;/strong&gt;  work where the CPU is the bottleneck, computation takes significant wall-clock time, and the result can be returned asynchronously:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Good Worker candidate&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Parsing large CSV/JSON/binary files&lt;/td&gt;
&lt;td&gt;Pure CPU, no DOM needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image processing (resizing, filtering, color transforms)&lt;/td&gt;
&lt;td&gt;Pixel math is CPU-intensive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cryptographic operations (hashing, encryption)&lt;/td&gt;
&lt;td&gt;Compute-heavy, available via &lt;code&gt;crypto.subtle&lt;/code&gt; in workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data compression/decompression&lt;/td&gt;
&lt;td&gt;CPU-bound, libraries like &lt;code&gt;pako&lt;/code&gt; work in workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine learning inference (&lt;code&gt;onnxruntime-web&lt;/code&gt;, &lt;code&gt;transformers.js&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Matrix math is exactly what workers are for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sorting/filtering/aggregating large datasets&lt;/td&gt;
&lt;td&gt;Heavy transforms on thousands of records&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Workers don't help with I/O-bound tasks:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bad Worker candidate&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt; calls&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt; is non-blocking on the main thread already; async/await handles this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Waiting for user input&lt;/td&gt;
&lt;td&gt;No DOM access in workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Updating React state&lt;/td&gt;
&lt;td&gt;You still have to &lt;code&gt;postMessage&lt;/code&gt; back to main thread; the bottleneck is in the message handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast, small computations&lt;/td&gt;
&lt;td&gt;Worker setup cost + clone overhead exceeds the computation time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The test: if your operation would be fast but for the raw CPU cycles it consumes, a Worker will help. If it's slow because it's waiting for network or disk, a Worker adds overhead without solving anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Worker lifecycle management
&lt;/h2&gt;

&lt;p&gt;Workers have a lifecycle: you create them, communicate with them, and eventually terminate them. Forgetting to terminate workers is a memory leak.&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&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./parser.worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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="c1"&gt;// Communicate&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="nx"&gt;data&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Terminate when done&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;span class="c1"&gt;// Error handling&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Worker error:&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;message&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;filename&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;lineno&lt;/span&gt;&lt;span class="p"&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;messageerror&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed to deserialize message from worker:&lt;/span&gt;&lt;span class="dl"&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unhandled exceptions inside a worker fire the &lt;code&gt;error&lt;/code&gt; event on the worker object in the main thread. The worker continues running after an error unless you call &lt;code&gt;terminate()&lt;/code&gt;. Errors inside workers don't propagate to &lt;code&gt;window.onerror&lt;/code&gt;  you must handle them explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comlink: making Workers feel like normal async functions
&lt;/h2&gt;

&lt;p&gt;The raw &lt;code&gt;postMessage&lt;/code&gt; API gets verbose fast. You end up building a request/response protocol with message types, correlation IDs, and manual promise management. &lt;strong&gt;Comlink&lt;/strong&gt; (a small Google library) abstracts all of that into a clean async function 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="c1"&gt;// parser.worker.js&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;parseCSV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;csvString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// heavy parsing work&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parsedRows&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="nf"&gt;transformData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// heavy transformation&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;transformedRows&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;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./parser.worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrap&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="c1"&gt;// Feels like a regular async call  no postMessage, no event listeners&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseCSV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;csvString&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;transformed&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transformData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comlink handles the message correlation, promise wrapping, and error propagation under the hood. The result is Worker code that reads like normal async JavaScript.&lt;/p&gt;

&lt;p&gt;The limitation: Comlink's proxy still relies on structured clone, so your function arguments and return values must be serializable. You can use &lt;code&gt;Comlink.transfer()&lt;/code&gt; to pass Transferable objects explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vite/webpack integration
&lt;/h2&gt;

&lt;p&gt;Modern bundlers handle Worker imports natively. In &lt;strong&gt;Vite&lt;/strong&gt;, the &lt;code&gt;new URL&lt;/code&gt; pattern is the standard:&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;// Vite: recognized as a Worker by the bundler&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./heavy-computation.worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// enables ES module syntax in the worker&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite bundles the worker file separately, handles its imports, and outputs a separate chunk. The worker can import npm packages normally.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;Webpack 5&lt;/strong&gt;, workers are similarly first-class:&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;// Webpack 5: same URL pattern&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid the older string-URL pattern (&lt;code&gt;new Worker("/worker.js")&lt;/code&gt;) in modern builds  it bypasses the bundler and forces you to manually manage the worker file in your public directory.&lt;/p&gt;




&lt;h2&gt;
  
  
  React integration: custom hook wrapping a Worker
&lt;/h2&gt;

&lt;p&gt;The cleanest pattern for using a Worker in React is a custom hook that manages the Worker lifecycle and exposes a simple async interface.&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;// useCSVParser.js&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workerRef&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiRef&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;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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./csv-parser.worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&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;workerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&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;apiRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrap&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="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="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;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;workerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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="nx"&gt;apiRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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="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;parseCSV&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;csvString&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiRef&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;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="s2"&gt;Worker not initialized&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="nx"&gt;apiRef&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="nf"&gt;parseCSV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;csvString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parseCSV&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 jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Component using the hook&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DataUploader&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;parseCSV&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCSVParser&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;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRows&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isParsing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsParsing&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleFileUpload&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="nf"&gt;setIsParsing&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;text&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;text&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;parsed&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;parseCSV&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="c1"&gt;// runs in worker thread&lt;/span&gt;
      &lt;span class="nf"&gt;setRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setIsParsing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;".csv"&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleFileUpload&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&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="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;isParsing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Parsing &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* spinner */&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&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="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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;useEffect&lt;/code&gt; cleanup calls &lt;code&gt;worker.terminate()&lt;/code&gt;  critical for preventing memory leaks when the component unmounts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Worker pools for parallelism
&lt;/h2&gt;

&lt;p&gt;A single Worker runs one task at a time. If you have multiple independent tasks, a &lt;strong&gt;Worker pool&lt;/strong&gt; lets you run them in parallel across multiple Worker instances.&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;// Simple worker pool&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkerPool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workerUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&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;hardwareConcurrency&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrap&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;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workerUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;module&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Round-robin dispatch&lt;/span&gt;
  &lt;span class="nf"&gt;getWorker&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;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;worker&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="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;args&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getWorker&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;](...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&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;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workers&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;w&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;releaseProxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;navigator.hardwareConcurrency&lt;/code&gt; returns the number of logical CPU cores. Using it as the pool size avoids over-subscribing the CPU. On a 4-core machine, 4 workers can genuinely run in parallel; 16 workers won't be faster, just more memory-hungry.&lt;/p&gt;




&lt;h2&gt;
  
  
  OffscreenCanvas: rendering off the main thread
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;OffscreenCanvas&lt;/code&gt;&lt;/strong&gt; lets you do canvas rendering (charts, WebGL scenes, image processing) entirely in a Worker. You transfer the canvas to the worker and it renders directly  no main-thread involvement after the initial transfer.&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;// main.js&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chart-canvas&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;offscreen&lt;/span&gt; &lt;span class="o"&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;transferControlToOffscreen&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;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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./chart-worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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="c1"&gt;// Transfer ownership  main thread can no longer access the canvas&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;canvas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offscreen&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// chart-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="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="s2"&gt;message&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;data&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;canvas&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&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;ctx&lt;/span&gt; &lt;span class="o"&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="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Draw whatever you want  this runs off the main thread&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... heavy chart rendering&lt;/span&gt;
    &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// yes, rAF works in workers with OffscreenCanvas&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;draw&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 particularly useful for live-updating charts (trading data, metrics dashboards) where the chart render work is expensive enough to cause jank on the main thread.&lt;/p&gt;




&lt;h2&gt;
  
  
  SharedArrayBuffer and Atomics
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SharedArrayBuffer&lt;/code&gt;&lt;/strong&gt; enables true shared memory between the main thread and workers  no cloning, no transfer. Both threads read and write the same memory region.&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;// main.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sharedBuffer&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;SharedArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 4KB&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sharedArray&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;Int32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sharedBuffer&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;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sharedBuffer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 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="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="s2"&gt;message&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;data&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;array&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;Int32Array&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="c1"&gt;// Both main thread and worker can now read/write array&lt;/span&gt;
  &lt;span class="nx"&gt;Atomics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;array&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="mi"&gt;42&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;strong&gt;&lt;code&gt;Atomics&lt;/code&gt;&lt;/strong&gt; provides atomic operations (compare-and-swap, load, store, wait/notify) for coordinating access to shared memory without data races.&lt;/p&gt;

&lt;p&gt;The catch: &lt;code&gt;SharedArrayBuffer&lt;/code&gt; requires &lt;strong&gt;cross-origin isolation&lt;/strong&gt; headers on your server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These headers limit what can be embedded in your page (no arbitrary cross-origin iframes or images without explicit CORP headers), which is a meaningful constraint for apps with third-party integrations.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SharedArrayBuffer&lt;/code&gt; is the right tool for high-throughput real-time scenarios  streaming telemetry, audio processing, game physics  where the clone overhead of &lt;code&gt;postMessage&lt;/code&gt; is itself a bottleneck. For most application use cases, structured clone is fast enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Before/after: moving CSV parsing to a Worker
&lt;/h2&gt;

&lt;p&gt;Here's the concrete impact of moving a 50,000-row CSV transform to a Worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (main thread):&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;Main thread task: 1,847ms
  ├── CSV string parse: 340ms
  ├── Row validation: 420ms
  ├── Data normalization: 680ms
  └── Aggregation: 407ms

Result: Input frozen, no frames painted, spinner doesn't spin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (Worker):&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;Main thread task: 12ms (postMessage + state update)
Worker thread: 1,847ms (same work, different thread)

Result: Input responsive, spinner animates, results arrive async
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The total computation time didn't change. But the user experience went from "frozen app" to "responsive app with an async operation in progress." That's the entire value proposition of Web Workers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main thread block time&lt;/td&gt;
&lt;td&gt;1,847ms&lt;/td&gt;
&lt;td&gt;~12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User input response&lt;/td&gt;
&lt;td&gt;Frozen&lt;/td&gt;
&lt;td&gt;Immediate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spinner animation&lt;/td&gt;
&lt;td&gt;Stutters&lt;/td&gt;
&lt;td&gt;Smooth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total computation time&lt;/td&gt;
&lt;td&gt;1,847ms&lt;/td&gt;
&lt;td&gt;1,847ms (in worker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle complexity&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Worker file + Comlink&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/web-workers-frontend-react/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/web-workers-frontend-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>webworkers</category>
      <category>javascript</category>
      <category>react</category>
    </item>
    <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://forem.com/helloashish99/opfs-the-browsers-built-in-filesystem-explained-o5i</link>
      <guid>https://forem.com/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://forem.com/helloashish99/network-optimization-in-react-spas-prefetching-13ak</link>
      <guid>https://forem.com/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://forem.com/helloashish99/dom-performance-on-mobile-lab-vs-real-device-reality-5gab</link>
      <guid>https://forem.com/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://forem.com/helloashish99/react-re-rendering-when-and-why-component-trees-update-3plg</link>
      <guid>https://forem.com/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://forem.com/helloashish99/ocr-in-the-browser-how-tesseractjs-makes-pdf-text-extraction-free-5ab2</link>
      <guid>https://forem.com/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://forem.com/helloashish99/why-client-side-pdf-tools-beat-server-uploads-privacy-speed-and-cost-21e1</link>
      <guid>https://forem.com/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://forem.com/helloashish99/a-developers-guide-to-image-formats-jpg-png-webp-avif-and-heic-mmo</link>
      <guid>https://forem.com/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://forem.com/helloashish99/building-slugs-that-dont-break-unicode-diacritics-and-edge-cases-an6</link>
      <guid>https://forem.com/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://forem.com/helloashish99/building-offline-first-web-apps-with-localstorage-a-practical-guide-5akk</link>
      <guid>https://forem.com/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>
  </channel>
</rss>
