<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ashish Kumar</title>
    <description>The latest articles on DEV Community by Ashish Kumar (@helloashish99).</description>
    <link>https://dev.to/helloashish99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3861766%2F0b3f531d-15d0-4bfa-975a-ce54df37aac8.png</url>
      <title>DEV Community: Ashish Kumar</title>
      <link>https://dev.to/helloashish99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/helloashish99"/>
    <language>en</language>
    <item>
      <title>Finding Duplicate Photos in the Browser (Without Uploading Your Library)</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sun, 17 May 2026 14:54:38 +0000</pubDate>
      <link>https://dev.to/helloashish99/finding-duplicate-photos-in-the-browser-without-uploading-your-library-1if5</link>
      <guid>https://dev.to/helloashish99/finding-duplicate-photos-in-the-browser-without-uploading-your-library-1if5</guid>
      <description>&lt;p&gt;My Downloads folder had the same vacation photos three times — once from Google Photos export, once from a WhatsApp forward, once from a backup script that renamed everything &lt;code&gt;IMG_20240315 (1).jpg&lt;/code&gt;. Different names. Same bytes. About 4 GB of waste I only noticed when my laptop started complaining about disk space.&lt;/p&gt;

&lt;p&gt;The obvious fix is a "duplicate photo finder." The obvious problem is what most of those tools ask you to do first: &lt;strong&gt;upload your entire library&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That felt wrong for photos I would never put on a random SaaS server. So I spent a few weeks building a client-side alternative — &lt;a href="https://dupshelf.renderlog.in" rel="noopener noreferrer"&gt;DupShelf&lt;/a&gt; — and this post is the engineering story behind it: what browsers can actually do today, where they still can't, and how to verify that your files never leave your machine.&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%2Fdupshelf.renderlog.in%2Fblog%2Fdupshelf-homepage.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%2Fdupshelf.renderlog.in%2Fblog%2Fdupshelf-homepage.png" alt="DupShelf landing page — private duplicate photo finder that runs in the browser" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why cloud duplicate scanners are a bad default for personal photos
&lt;/h2&gt;

&lt;p&gt;Server-side duplicate finders follow a simple pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You upload thousands of files.&lt;/li&gt;
&lt;li&gt;Their backend hashes or perceptually compares them.&lt;/li&gt;
&lt;li&gt;You get a report (and sometimes a "delete all" button).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That made sense in 2012 when JavaScript couldn't read a folder from disk. In 2026 it's mostly inertia.&lt;/p&gt;

&lt;p&gt;The costs for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt; — wedding albums, kids' photos, medical scans, work screenshots. Uploading is an act of trust with unclear retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time&lt;/strong&gt; — home upload speeds are asymmetric. Shipping 30 GB up often takes longer than hashing locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control&lt;/strong&gt; — many tools optimize for "free up space now" with aggressive delete flows. One mis-click on a similar-looking burst is worse than keeping a duplicate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Client-side duplicate finding flips the model: &lt;strong&gt;your CPU does the work, your disk is the only storage that matters, and nothing is deleted unless you decide to.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Exact duplicates vs "similar" photos (be precise about what you promise)
&lt;/h2&gt;

&lt;p&gt;Users say "find duplicate photos" but mean two different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What users imagine&lt;/th&gt;
&lt;th&gt;What most engineers build first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Same file copied twice&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Exact duplicate&lt;/strong&gt; — identical bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Burst shots, crops, re-saved JPEGs&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Similar image&lt;/strong&gt; — perceptual hash, ML embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exact matching is deterministic: hash the file, compare hashes, done. Renamed copies, re-exports, and "Copy of…" files still match even when metadata differs.&lt;/p&gt;

&lt;p&gt;Similar matching is fuzzy: two portraits taken a second apart might group together; a heavily edited version might not. It needs different algorithms (pHash, CLIP, etc.), more CPU, and more false positives.&lt;/p&gt;

&lt;p&gt;DupShelf ships &lt;strong&gt;exact duplicates only&lt;/strong&gt; for now — SHA-256 over file content, grouped by hash. That's intentional: it's the safest first cleanup pass. You only remove files that are provably identical to another file in the set.&lt;/p&gt;

&lt;p&gt;When similar-image mode exists, it should be optional and loudly labeled. Mixing "similar" results into an exact-duplicate UI is how tools earn angry reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  How local duplicate detection actually works
&lt;/h2&gt;

&lt;p&gt;At a high level the pipeline is boring on purpose:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enumerate&lt;/strong&gt; files in a folder (recursive) or from a file picker batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter&lt;/strong&gt; to image types the browser can treat as blobs (&lt;code&gt;jpeg&lt;/code&gt;, &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;webp&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash&lt;/strong&gt; each file's bytes (DupShelf uses SHA-256 via Web Crypto).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group&lt;/strong&gt; files that share a hash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review&lt;/strong&gt; — human picks one keeper per group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Act&lt;/strong&gt; — move extras to a subfolder or export a CSV for manual cleanup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No magic. The interesting parts are browser APIs and UX around large folders.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hashing at scale
&lt;/h3&gt;

&lt;p&gt;SHA-256 in the browser is fast enough for real libraries on a desktop. The slow part is often &lt;strong&gt;reading&lt;/strong&gt; files from disk through the File System Access API, not the hash itself.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stream or chunk&lt;/strong&gt; large files if memory is tight (DupShelf reads blobs sized for typical photos).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run hashing off the main thread&lt;/strong&gt; with Web Workers so the UI stays responsive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show honest progress&lt;/strong&gt; — "hashing" is the slow phase users feel on 8k+ file folders; hiding that behind a spinner breeds distrust.&lt;/li&gt;
&lt;/ul&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%2Fdupshelf.renderlog.in%2Fblog%2Fdupshelf-scanning.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%2Fdupshelf.renderlog.in%2Fblog%2Fdupshelf-scanning.png" alt="Scanning progress while DupShelf hashes thousands of images locally" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why filenames lie
&lt;/h3&gt;

&lt;p&gt;Backup tools, messengers, and sync clients rename files constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;photo.jpg&lt;/code&gt; → &lt;code&gt;photo (1).jpg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DSC_0042.NEF&lt;/code&gt; → &lt;code&gt;DSC_0042-copy.NEF&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Same bytes, new path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Content hashing ignores names. That's the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The File System Access API: folder scan and safe moves
&lt;/h2&gt;

&lt;p&gt;Chrome and Edge on desktop support picking a &lt;strong&gt;directory handle&lt;/strong&gt; with &lt;code&gt;showDirectoryPicker()&lt;/code&gt;. DupShelf walks it recursively, builds a list of image refs, and keeps the handle so you can &lt;strong&gt;move&lt;/strong&gt; non-keepers later.&lt;/p&gt;

&lt;p&gt;Important nuances:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read vs write&lt;/strong&gt; — scanning needs read access. Moving duplicates into a subfolder needs write permission; the browser may prompt again. That's good — explicit consent beats silent disk writes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari / Firefox&lt;/strong&gt; — no full-folder picker today. Users can still add batches via file input, drag-and-drop, or paste. The tool should say that plainly instead of failing mysteriously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure context&lt;/strong&gt; — folder access requires HTTPS (or localhost during development).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DupShelf never auto-deletes. The write path creates a folder named &lt;code&gt;dupshelf-duplicate-images&lt;/code&gt; inside your library and moves extras there in grouped subfolders. You verify in Finder or Explorer, then delete when ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  OPFS for session restore (optional but nice)
&lt;/h2&gt;

&lt;p&gt;If you've read about the &lt;a href="https://dev.to/helloashish99/opfs-the-browsers-built-in-filesystem-explained-o5i"&gt;Origin Private File System&lt;/a&gt;, it's a good fit for tool state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scan results (groups, keeper choices, file metadata) can be large.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localStorage&lt;/code&gt; is the wrong tool — size limits and synchronous JSON stringify hurt.&lt;/li&gt;
&lt;li&gt;OPFS gives you private, origin-scoped file storage for structured session snapshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DupShelf persists completed scans to OPFS so a tab refresh doesn't throw away an hour of hashing. File &lt;strong&gt;handles&lt;/strong&gt; don't survive reloads — you reconnect the same folder to move files again. That's a browser security rule, not a product bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review UX: the product is the grouping UI
&lt;/h2&gt;

&lt;p&gt;Finding duplicates is table stakes. &lt;strong&gt;Reviewing&lt;/strong&gt; them is the product.&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%2Fdupshelf.renderlog.in%2Fblog%2Fdupshelf-review.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%2Fdupshelf.renderlog.in%2Fblog%2Fdupshelf-review.png" alt="Review screen with duplicate groups, space to free, and move/export actions" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What worked in testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Virtualize the group list&lt;/strong&gt; — a 900-group library must not render 900 DOM cards at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One keeper per group&lt;/strong&gt; — tap a thumbnail to keep it; everything else in the group is a move candidate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show recoverable space&lt;/strong&gt; — sum file sizes minus keepers. People need a number to justify an afternoon of cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV export&lt;/strong&gt; — for users who want a checklist in Excel or a script, not in-app moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify folder&lt;/strong&gt; — re-enumerate and re-hash after manual changes on disk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Real use cases (where local exact dedup wins)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Messy Downloads folders&lt;/strong&gt; — screenshots, memes, and attachments with &lt;code&gt;(1)&lt;/code&gt; in the name. Low risk, high reward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-backup cleanup&lt;/strong&gt; — dedupe before Time Machine or copying to an external drive. Smaller archives, faster restores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WhatsApp / Telegram exports&lt;/strong&gt; — forwarded photos land with new names daily. Exact dedup catches true copies; it won't merge burst shots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photographer delivery folders&lt;/strong&gt; — verify you didn't zip the same export twice before sending to a client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy-sensitive libraries&lt;/strong&gt; — anything you legally or personally shouldn't upload to a third-party "free scanner."&lt;/p&gt;

&lt;p&gt;DupShelf has longer guides on these scenarios at &lt;a href="https://dupshelf.renderlog.in/#guides" rel="noopener noreferrer"&gt;dupshelf.renderlog.in&lt;/a&gt; if you want SEO-shaped deep dives; the workbench itself is at &lt;a href="https://dupshelf.renderlog.in/app" rel="noopener noreferrer"&gt;dupshelf.renderlog.in/app&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify a tool really runs locally
&lt;/h2&gt;

&lt;p&gt;Before you point any duplicate finder at a sensitive folder:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Network tab&lt;/strong&gt; — start a scan, filter by your domain. Image bytes should not POST to the server. Marketing pages may load analytics; the scan itself should not upload files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline test&lt;/strong&gt; — load the app once, disconnect, refresh. Client-side tools should still open (scanning may need the tab that already has permission).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the move contract&lt;/strong&gt; — does it delete in-app or move to a folder you control?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DupShelf is static-hosted (Next.js on Vercel) with no backend that accepts your photos. The privacy policy describes folder permissions in plain language: &lt;a href="https://dupshelf.renderlog.in/privacy" rel="noopener noreferrer"&gt;dupshelf.renderlog.in/privacy&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs (honest list)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Browser memory&lt;/strong&gt; — hashing 20k RAW files in one tab on an 8 GB machine can hurt. Start with a subfolder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact only&lt;/strong&gt; — re-saved JPEGs at different quality settings are different bytes. Similar detection is a different product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Desktop-first&lt;/strong&gt; — folder scan + move is built for Chrome/Edge on a laptop. Mobile can add files manually; it's not the primary workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Initial JS bundle&lt;/strong&gt; — a serious tool ships workers, virtualization, and OPFS helpers. First visit costs more than a landing-page-only site. Cache helps after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped (and what I deliberately didn't)
&lt;/h2&gt;

&lt;p&gt;DupShelf is free, no account, no upload. Core loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose folder or add batches&lt;/li&gt;
&lt;li&gt;Scan with visible progress&lt;/li&gt;
&lt;li&gt;Review groups, pick keepers&lt;/li&gt;
&lt;li&gt;Move to &lt;code&gt;dupshelf-duplicate-images&lt;/code&gt; or export CSV&lt;/li&gt;
&lt;li&gt;Session restore + undo last move for folder scans&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deliberately &lt;strong&gt;not&lt;/strong&gt; in v1: cloud sync, AI similarity, auto-delete, mobile folder access, or upsell walls. Those are either privacy regressions or scope explosions.&lt;/p&gt;

&lt;p&gt;If you maintain a photo library on disk and you've been putting off cleanup because cloud tools feel gross, try a local pass first. Exact duplicates are the safest win — you'll know the bytes matched before you move anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://dupshelf.renderlog.in/app" rel="noopener noreferrer"&gt;dupshelf.renderlog.in/app&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;How it works (marketing):&lt;/strong&gt; &lt;a href="https://dupshelf.renderlog.in" rel="noopener noreferrer"&gt;dupshelf.renderlog.in&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build browser-based tools under the Renderlog umbrella — client-side when the platform allows, honest about limits when it doesn't. DupShelf is the duplicate-photo piece; if this post helped, the workflow might save you a few gigabytes this weekend.&lt;/em&gt;&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;DupShelf&lt;/strong&gt; — &lt;a href="https://dupshelf.renderlog.in" rel="noopener noreferrer"&gt;https://dupshelf.renderlog.in&lt;/a&gt; (find exact duplicate photos locally, move or export CSV, no upload)&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>photography</category>
    </item>
    <item>
      <title>What Is Preact and Why Does a 3KB Version of React Exist</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 16 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/what-is-preact-and-why-does-a-3kb-version-of-react-exist-2pk1</link>
      <guid>https://dev.to/helloashish99/what-is-preact-and-why-does-a-3kb-version-of-react-exist-2pk1</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/build-bundles-treeshaking-code-splitting/" rel="noopener noreferrer"&gt;JavaScript Bundle Analysis: Tree Shaking and Code Splitting Explained&lt;/a&gt; covers why the size difference between React and Preact matters and how bundle size translates to parse time on real hardware.&lt;/p&gt;

&lt;p&gt;React is approximately 45KB minified and gzipped (react + react-dom combined). Preact is approximately 3KB. Both let you write JSX components with hooks. Both produce the same UI. The question "why does Preact exist" has a specific technical answer, not a vague one about bundle size. React ships features that most applications never use, and those features are not cheap. Preact ships without them. The 42KB difference is exactly those features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; What is actually inside React's 45KB that Preact does not include, what the API compatibility story looks like, which applications benefit most from the switch, and the one major React feature that Preact does not support at all.&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%2Fpcuwwkiucgt7gjpyrifs.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%2Fpcuwwkiucgt7gjpyrifs.png" alt="Comparison diagram of React versus Preact bundle sizes, showing which features each library includes and what Preact removes to achieve its 3KB size." width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What React's 45KB actually contains
&lt;/h2&gt;

&lt;p&gt;React is split into two packages: &lt;code&gt;react&lt;/code&gt; (core API, ~7KB) and &lt;code&gt;react-dom&lt;/code&gt; (renderer for the browser, ~38KB). The &lt;code&gt;react-dom&lt;/code&gt; package is where most of the size lives, and it contains several systems that Preact either removes entirely or significantly simplifies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synthetic event system.&lt;/strong&gt; React does not attach event listeners directly to DOM elements. It attaches a single event listener to the root of the document and uses event delegation to handle all events across the component tree. When an event fires, React's synthetic event system normalizes it into a cross-browser compatible event object before passing it to your handler.&lt;/p&gt;

&lt;p&gt;This system exists because in 2013 when React was created, browser event handling was inconsistent enough that normalization was necessary. Today, modern browsers are far more consistent, but the synthetic event system remains because removing it would be a breaking change.&lt;/p&gt;

&lt;p&gt;Preact attaches event listeners directly to DOM elements using &lt;code&gt;addEventListener&lt;/code&gt;. It calls native browser events. No normalization layer. No delegation. Most applications running on modern browsers do not notice the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy context API support.&lt;/strong&gt; React ships code for both the old context API (&lt;code&gt;childContextTypes&lt;/code&gt;, &lt;code&gt;contextTypes&lt;/code&gt;) and the new one (&lt;code&gt;createContext&lt;/code&gt;). Legacy code and third-party libraries that still use the old API continue to work. This backward compatibility code adds size.&lt;/p&gt;

&lt;p&gt;Preact supports only the modern &lt;code&gt;createContext&lt;/code&gt; API. Any library that uses the old context API will not work with Preact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Development mode tooling.&lt;/strong&gt; React has two bundles for every module: a development build (with warnings, error messages, and development tooling) and a production build. The bundler switches between them at build time. Even the production build retains some infrastructure for React DevTools integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fiber scheduling infrastructure.&lt;/strong&gt; React's Fiber architecture includes a complete implementation of a cooperative scheduler, work loops, priority lanes, and the infrastructure for Concurrent Mode features. This is the machinery behind &lt;code&gt;useTransition&lt;/code&gt;, &lt;code&gt;useDeferredValue&lt;/code&gt;, &lt;code&gt;Suspense&lt;/code&gt; for data fetching, and streaming SSR. It is substantial code, and it is always present in your bundle even if you use none of the concurrent features.&lt;/p&gt;

&lt;p&gt;Preact does not have a scheduler. It processes updates synchronously. This is simpler and smaller, but it means Preact does not support Concurrent Mode features.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Preact keeps
&lt;/h2&gt;

&lt;p&gt;Preact supports essentially the full modern React component API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Function components with all core hooks: &lt;code&gt;useState&lt;/code&gt;, &lt;code&gt;useEffect&lt;/code&gt;, &lt;code&gt;useReducer&lt;/code&gt;, &lt;code&gt;useContext&lt;/code&gt;, &lt;code&gt;useRef&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;, &lt;code&gt;useLayoutEffect&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;JSX (same syntax, same compilation output)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createContext&lt;/code&gt; and &lt;code&gt;useContext&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;forwardRef&lt;/code&gt; and &lt;code&gt;createRef&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;React.memo&lt;/code&gt; (called &lt;code&gt;memo&lt;/code&gt; in Preact)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Suspense&lt;/code&gt; (for lazy-loaded components via &lt;code&gt;React.lazy()&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Error boundaries (class components only, same as React)&lt;/li&gt;
&lt;li&gt;Portals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most application code, the API surface is identical. Components written for React generally work in Preact without modification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Preact/compat: the drop-in replacement layer
&lt;/h2&gt;

&lt;p&gt;For projects that want to migrate from React to Preact, or that use third-party libraries expecting React, Preact provides a compatibility layer called &lt;code&gt;preact/compat&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;// vite.config.ts or webpack.config.js&lt;/span&gt;
&lt;span class="c1"&gt;// Alias react and react-dom to preact/compat&lt;/span&gt;

  &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;alias&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;react&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;preact/compat&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;react-dom&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;preact/compat&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;react-dom/test-utils&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;preact/test-utils&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;react/jsx-runtime&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;preact/jsx-runtime&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this alias in place, any import of &lt;code&gt;react&lt;/code&gt; or &lt;code&gt;react-dom&lt;/code&gt; in your code or in node_modules resolves to Preact's compatibility layer. Libraries like React Router, React Query, Radix UI, and most of the ecosystem work without any changes to their source code.&lt;/p&gt;

&lt;p&gt;The compatibility layer adds some size on top of the 3KB Preact core, but the total is still around 4 to 5KB, compared to React's 45KB.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the performance difference shows up
&lt;/h2&gt;

&lt;p&gt;On a MacBook M3 or any modern development machine, the difference between React and Preact is usually imperceptible. The extra 42KB parses in under a millisecond. This is why most large applications with fast deployment targets never feel compelled to switch.&lt;/p&gt;

&lt;p&gt;The difference becomes measurable in specific contexts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low-end Android devices.&lt;/strong&gt; JavaScript parse time scales with bundle size and with CPU speed. On a mid-range Android phone with a slower CPU, 42KB of JavaScript takes meaningfully longer to parse than it does on a development machine. Cutting 42KB from the bundle has a proportionally larger effect on slower CPUs than on fast ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedded widgets.&lt;/strong&gt; If you are building a component that will be embedded on third-party pages (chat widgets, feedback forms, survey tools, analytics dashboards), you do not control the host page's performance budget. The host page has its own React bundle if it is a React app. Your embedded widget cannot share that React installation. Shipping 45KB of React in your widget is a significant imposition on the host page. Shipping 3KB is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High-traffic marketing sites with strict performance budgets.&lt;/strong&gt; Sites where LCP targets are aggressive and every kilobyte is audited. Trading 45KB of React for 4KB of Preact can be the difference between passing and failing a performance budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Micro-frontends where React cannot be shared.&lt;/strong&gt; If different micro-frontend bundles are versioned independently and cannot share a single React installation, each bundle that requires React pays the 45KB cost. Preact's 3KB cost is less significant to pay multiple times.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one major missing feature: Concurrent Mode
&lt;/h2&gt;

&lt;p&gt;Preact does not support React's Concurrent Mode features. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;useTransition&lt;/code&gt; and &lt;code&gt;startTransition&lt;/code&gt; exist in Preact's API but do not schedule work the way React's scheduler does. They behave more like synchronous updates with a lower priority hint rather than truly preemptible background rendering.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useDeferredValue&lt;/code&gt; has limited effectiveness for the same reason.&lt;/li&gt;
&lt;li&gt;Streaming SSR via &lt;code&gt;renderToReadableStream&lt;/code&gt; is not supported.&lt;/li&gt;
&lt;li&gt;React Server Components are not supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For applications that rely on &lt;code&gt;useTransition&lt;/code&gt; to keep heavy re-renders from blocking user input, or that use React Server Components and streaming SSR, Preact is not a viable replacement.&lt;/p&gt;

&lt;p&gt;For applications using hooks, standard data fetching (React Query, SWR), React Router, and the standard component model without concurrent features, Preact is functionally identical from the developer's perspective.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who uses Preact in production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shopify&lt;/strong&gt; uses Preact for several of its storefront components because their theme ecosystem requires JavaScript bundles that load on every merchant's store. Reducing bundle size directly reduces Time to Interactive for millions of storefronts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google&lt;/strong&gt; has several internal tools and projects built on Preact, maintained partly by Jason Miller, Preact's creator, who works at Google. The Google Search result page has used Preact for interactive elements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smaller SaaS products with widget offerings&lt;/strong&gt; frequently use Preact to keep their embeddable widgets lightweight.&lt;/p&gt;

&lt;p&gt;The pattern is consistent: teams choose Preact when they either cannot afford React's bundle size (constraint) or do not need its advanced runtime features (simplicity). Most large SPAs do not switch because the difference does not justify the migration risk and the loss of Concurrent Mode capabilities.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making the decision
&lt;/h2&gt;

&lt;p&gt;The decision framework:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New application, no performance budget constraints&lt;/td&gt;
&lt;td&gt;React. Larger ecosystem, better concurrent features, more future-proof.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New application, strict bundle size budget&lt;/td&gt;
&lt;td&gt;Preact with preact/compat. Near-identical DX, fraction of the size.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Existing React app, minor performance concerns&lt;/td&gt;
&lt;td&gt;Do not migrate. The risk-to-benefit ratio is poor.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedded widget or micro-frontend&lt;/td&gt;
&lt;td&gt;Preact is worth evaluating.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need &lt;code&gt;useTransition&lt;/code&gt; for heavy re-renders&lt;/td&gt;
&lt;td&gt;React. Preact cannot match this.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need React Server Components&lt;/td&gt;
&lt;td&gt;React. Preact has no equivalent.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Preact exists because React's size is not an accident of poor engineering; it is the result of deliberate decisions to support a broad range of use cases and to maintain backward compatibility. For applications that do not need those use cases, Preact removes the cost without removing the API. Understanding what the cost actually is makes the decision straightforward.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/preact-vs-react-why-3kb-alternative-exists/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/preact-vs-react-why-3kb-alternative-exists/&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>react</category>
      <category>preact</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Service Worker Caching Strategies: Cache-First, Network-First, and SWR</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Fri, 15 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/service-worker-caching-strategies-cache-first-network-first-and-swr-2pan</link>
      <guid>https://dev.to/helloashish99/service-worker-caching-strategies-cache-first-network-first-and-swr-2pan</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/network-optimization-spa-react/" rel="noopener noreferrer"&gt;Network Optimization for SPAs and React Apps&lt;/a&gt; covers the broader network optimization picture including HTTP caching and API request optimization.&lt;/p&gt;

&lt;p&gt;A service worker is a JavaScript file that runs in a background thread, separate from your main application. It intercepts every network request your page makes and can respond from cache, modify the request, or let it pass through to the network unchanged. For a returning visitor, a well-configured service worker means many requests never touch the network at all. The page loads from cache at local disk speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; What service workers can and cannot do, the three core caching strategies (cache-first, network-first, stale-while-revalidate), when each strategy is correct for which resource type, and how Workbox makes implementing these strategies practical without writing the interception logic by hand.&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%2F7jcrcqs8c0mlxuzcruu7.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%2F7jcrcqs8c0mlxuzcruu7.png" alt="Diagram showing three caching strategies: cache-first serving from cache immediately, network-first checking network before cache, and stale-while-revalidate serving cache then updating in background." width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What a service worker actually does
&lt;/h2&gt;

&lt;p&gt;A service worker is registered by your application JavaScript and installed by the browser. Once installed, it intercepts all network requests made from pages on its origin. This includes requests for HTML, CSS, JavaScript, images, fonts, and API calls.&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;// Register the service worker from your main application&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&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;load&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&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;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sw.js&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service worker file (&lt;code&gt;sw.js&lt;/code&gt;) runs in a separate worker thread. It has access to the Cache Storage API, which is separate from the HTTP cache the browser manages automatically. The Cache Storage API is under your complete control: you decide what to cache, when to cache it, how long to keep it, and when to delete 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="c1"&gt;// Inside sw.js: intercept a fetch event and respond from cache&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="nx"&gt;event&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;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="nx"&gt;caches&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;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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cachedResponse&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;cachedResponse&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;cachedResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Serve from cache&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;fetch&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="c1"&gt;// Fall through to network&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;This is the basic cache-first pattern in raw form. Every real application needs more nuance: what if the cache is stale? What if certain resources should never be served from cache? What if the network is unavailable? Writing and maintaining all of this logic by hand is error-prone, which is why Workbox exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  Strategy 1: Cache-First
&lt;/h2&gt;

&lt;p&gt;Cache-first means: check the cache first. If a response is in cache, return it immediately without hitting the network. Only go to the network if the resource is not in cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use this for:&lt;/strong&gt; Versioned static assets. JavaScript bundles, CSS files, images, and fonts that have content hashes in their filenames (&lt;code&gt;main-a1b2c3.js&lt;/code&gt;). When the content changes, the filename changes, so cached versions are never stale. The old cache entry becomes unreachable by the new URL and gets cleaned up during the next cache update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it works for these resources:&lt;/strong&gt; Versioned assets are safe to serve from cache forever because the filename is tied to the content. A browser serving &lt;code&gt;main-a1b2c3.js&lt;/code&gt; from cache in six months will serve the exact same content that was served when the file was first cached. The application code that requests this file will either request the cached version (nothing changed) or request a different URL (the content changed and the new build output has a new hash).&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;// Workbox: cache-first for all versioned assets&lt;/span&gt;

&lt;span class="nf"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="c1"&gt;// Match hashed JavaScript and CSS files&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="o"&gt;=&amp;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;destination&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&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;destination&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style&lt;/span&gt;&lt;span class="dl"&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;CacheFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;static-assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;plugins&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;ExpirationPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 1 year&lt;/span&gt;
        &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&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="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;strong&gt;What to avoid:&lt;/strong&gt; Using cache-first for HTML documents or unversioned API endpoints. If your HTML is served from cache and you deploy a new version, users will continue loading the old HTML until the cache expires. HTML should use network-first or stale-while-revalidate so users always get current navigation and content structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Strategy 2: Network-First
&lt;/h2&gt;

&lt;p&gt;Network-first means: try the network first. If the network succeeds, serve the response and update the cache. If the network fails (offline, timeout, server error), fall back to the cached version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use this for:&lt;/strong&gt; HTML pages, API endpoints serving fresh data, and any resource where stale content would cause problems. The user always gets the freshest version when online. When offline, they get a reasonable fallback from cache.&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;// Workbox: network-first for HTML pages&lt;/span&gt;

&lt;span class="nf"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&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="o"&gt;=&amp;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;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// HTML navigation requests&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NetworkFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;networkTimeoutSeconds&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="c1"&gt;// Fall back to cache if network takes more than 3 seconds&lt;/span&gt;
    &lt;span class="na"&gt;plugins&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;ExpirationPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Keep cached pages for 30 days&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;The &lt;code&gt;networkTimeoutSeconds&lt;/code&gt; option is important for offline-capable applications. Without it, network-first will wait indefinitely for a network response even when the user is offline, only falling back to cache after the connection eventually times out (which can take 30-60 seconds). Setting a 3-second timeout means users in poor network conditions get a cached response quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For API endpoints serving user-specific or time-sensitive data:&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;// Workbox: network-first for API calls&lt;/span&gt;
&lt;span class="nf"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&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="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="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NetworkFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cacheName&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-responses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;networkTimeoutSeconds&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="na"&gt;plugins&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;ExpirationPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Cache API responses for 1 day&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;The cached API response is not perfectly fresh, but for most use cases it is better than showing an error page when the network is unavailable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Strategy 3: Stale-While-Revalidate
&lt;/h2&gt;

&lt;p&gt;Stale-while-revalidate means: serve the cached version immediately (stale), and simultaneously fetch a fresh version from the network in the background (revalidate). The next request gets the fresh version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use this for:&lt;/strong&gt; Resources where some staleness is acceptable and speed is more important than absolute freshness. Navigation assets that change infrequently, avatar images, icon sets, and any resource where showing a slightly old version on the current visit is fine as long as the next visit gets the update.&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;// Workbox: stale-while-revalidate for images&lt;/span&gt;

&lt;span class="nf"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&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="o"&gt;=&amp;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;destination&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&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;StaleWhileRevalidate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;plugins&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;ExpirationPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 30 days&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;The user experience for stale-while-revalidate: on the first visit, there is no cache, so the image loads from the network normally. On subsequent visits, the cached image appears instantly. In the background, the service worker fetches a fresh version from the network. If the image changed, the fresh version replaces the cached version. On the visit after that, the updated image appears instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is powerful for most non-critical resources:&lt;/strong&gt; Users almost never notice if an avatar image or icon set is one version old. What they notice is speed. Stale-while-revalidate gives them cache speed on every visit while keeping the cache reasonably fresh.&lt;/p&gt;




&lt;h2&gt;
  
  
  Precaching: caching at install time
&lt;/h2&gt;

&lt;p&gt;All three strategies above are runtime caching: resources get cached when the user visits pages that request them. Precaching is different: you tell Workbox which resources to cache immediately when the service worker installs, before the user has visited any page.&lt;/p&gt;

&lt;p&gt;Precaching is used for the application shell: the minimal HTML, CSS, and JavaScript needed to render a working (possibly empty) UI. When the user opens the app, even before any network requests complete, the service worker serves the precached shell and the app renders immediately.&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;// Workbox: precache the app shell at install time&lt;/span&gt;

&lt;span class="c1"&gt;// This list is generated by Workbox at build time (via workbox-build or the Vite plugin)&lt;/span&gt;
&lt;span class="c1"&gt;// It contains all files from your build output with their content hashes&lt;/span&gt;
&lt;span class="nf"&gt;precacheAndRoute&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="nx"&gt;__WB_MANIFEST&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;self.__WB_MANIFEST&lt;/code&gt; placeholder is replaced at build time by the Workbox build tool with an array of all your static assets and their content hashes. When a user installs the service worker for the first time, Workbox fetches all these assets and caches them. On subsequent visits, they are served from cache instantly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting up Workbox in a Vite project
&lt;/h2&gt;

&lt;p&gt;The easiest setup for modern projects uses the &lt;code&gt;vite-plugin-pwa&lt;/code&gt; plugin, which integrates Workbox configuration into the Vite build process:&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="nc"&gt;VitePWA&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;registerType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;autoUpdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Precache all files matching these patterns from the build output&lt;/span&gt;
        &lt;span class="na"&gt;globPatterns&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;**/*.{js,css,html,ico,png,webp,svg,woff2}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;

        &lt;span class="c1"&gt;// Runtime caching rules&lt;/span&gt;
        &lt;span class="na"&gt;runtimeCaching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="c1"&gt;// Cache-first for versioned assets&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;urlPattern&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;js|css&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CacheFirst&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;static-resources&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&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="c1"&gt;// Network-first for API calls&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;urlPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/^https:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;api&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;example&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NetworkFirst&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;cacheName&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-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;networkTimeoutSeconds&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="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&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="c1"&gt;// Stale-while-revalidate for images&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;urlPattern&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;png|jpg|webp|svg|gif&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;StaleWhileRevalidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&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;60&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;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;This configuration handles the most common case: versioned JavaScript and CSS with cache-first, API responses with network-first and a 5-second fallback timeout, and images with stale-while-revalidate.&lt;/p&gt;




&lt;h2&gt;
  
  
  What service workers cannot cache
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Opaque responses.&lt;/strong&gt; Requests to third-party origins without CORS headers return "opaque" responses. Opaque responses can be cached, but their size is counted as 7MB regardless of actual size due to security restrictions. Caching too many opaque responses can exhaust cache storage limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Range requests.&lt;/strong&gt; Video streaming uses HTTP range requests to fetch specific byte ranges. Service workers can intercept these, but handling them correctly requires specific logic. The default Workbox strategies do not handle range requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resources requiring authentication in every request.&lt;/strong&gt; If a resource requires a fresh token in each request and you cannot cache the token, you cannot meaningfully cache the resource either.&lt;/p&gt;




&lt;h2&gt;
  
  
  The return visit experience
&lt;/h2&gt;

&lt;p&gt;After a service worker is installed and caching is running correctly, a returning visitor's experience is qualitatively different from a first visit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User opens the app&lt;/li&gt;
&lt;li&gt;Service worker intercepts navigation request&lt;/li&gt;
&lt;li&gt;Precached &lt;code&gt;index.html&lt;/code&gt; returns instantly from service worker cache&lt;/li&gt;
&lt;li&gt;Browser parses HTML, requests JavaScript and CSS&lt;/li&gt;
&lt;li&gt;Service worker intercepts those requests, returns from cache instantly&lt;/li&gt;
&lt;li&gt;App renders with no network requests at all&lt;/li&gt;
&lt;li&gt;In background, service worker fetches fresh API data&lt;/li&gt;
&lt;li&gt;UI updates with fresh data when background fetch completes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 through 6 complete in under 100ms on most devices, regardless of network speed, because no network requests were made. The perceived load time is the time to render from cache, not the time to fetch from a server.&lt;/p&gt;

&lt;p&gt;This is the correct mental model for why service workers matter: not as a way to make network requests faster, but as a way to eliminate network requests entirely for everything that can safely be served from cache.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/service-worker-caching-strategies-workbox/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/service-worker-caching-strategies-workbox/&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>javascript</category>
      <category>caching</category>
      <category>browser</category>
    </item>
    <item>
      <title>Preload vs Prefetch vs Preconnect: When to Use Each Resource Hint</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Thu, 14 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/preload-vs-prefetch-vs-preconnect-when-to-use-each-resource-hint-4570</link>
      <guid>https://dev.to/helloashish99/preload-vs-prefetch-vs-preconnect-when-to-use-each-resource-hint-4570</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/critical-css-inlining-render-blocking-explained/" rel="noopener noreferrer"&gt;Critical CSS: What Render-Blocking Really Means and How Inlining Fixes It&lt;/a&gt; covers the related optimization of eliminating render-blocking CSS, which pairs well with resource hint strategy.&lt;/p&gt;

&lt;p&gt;The three resource hints (&lt;code&gt;preload&lt;/code&gt;, &lt;code&gt;prefetch&lt;/code&gt;, &lt;code&gt;preconnect&lt;/code&gt;) look similar enough that developers regularly use them interchangeably. They are not interchangeable. Each one tells the browser to do a different thing with a different priority and a different timing. Using &lt;code&gt;preload&lt;/code&gt; when you meant &lt;code&gt;prefetch&lt;/code&gt; puts a resource in the high-priority queue and competes with CSS and critical JavaScript, potentially making the current page slower while trying to speed up a future page. Getting this wrong is worse than not using resource hints at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; What each hint actually tells the browser, when each one fires in the page lifecycle, the browser's priority system and how hints fit into it, and concrete examples of when to use each.&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%2Fxh7xi5w6gnfbpwze78el.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%2Fxh7xi5w6gnfbpwze78el.png" alt="Timeline diagram showing when preload, prefetch, and preconnect fire relative to the page load lifecycle and how they interact with the browser's resource priority queue." width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Preload: this page needs this resource soon
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;preload&lt;/code&gt; tells the browser to fetch a specific resource at high priority because the current page will need it soon. It is a declaration of intent for the current page, not a future one.&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;!-- Preload the LCP image before the HTML parser finds the &amp;lt;img&amp;gt; tag --&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;"/hero.webp"&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;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Preload a font that is referenced in CSS (CSS would discover it later) --&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;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;span class="c"&gt;&amp;lt;!-- Preload a script that will be needed immediately after parsing --&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;"/analytics.js"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&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;as&lt;/code&gt; attribute is required and tells the browser what type of resource is being preloaded. This is critical because different resource types have different priorities and different caching behaviors. Without &lt;code&gt;as&lt;/code&gt;, the browser fetches the resource but cannot apply the correct priority, content security policy, or caching headers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource type&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;as&lt;/code&gt; value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;script&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS stylesheet&lt;/td&gt;
&lt;td&gt;&lt;code&gt;style&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Font&lt;/td&gt;
&lt;td&gt;&lt;code&gt;font&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;td&gt;&lt;code&gt;video&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fetch/XHR data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fetch&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The use case for preload:&lt;/strong&gt; A resource that is on the critical path for the current page but is discovered late by the browser's preloader. The browser preloader scans HTML as it arrives looking for resources to fetch. It finds &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags. It does not execute JavaScript, so it cannot discover resources loaded dynamically. It cannot read CSS to discover the fonts referenced in &lt;code&gt;url()&lt;/code&gt;. Preload is how you tell it about resources it would otherwise miss until late in the parse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most common correct use:&lt;/strong&gt; Preloading the LCP image when it is a background image set by CSS (the preloader cannot discover it), preloading fonts that appear in the CSS (the preloader cannot read CSS), and preloading scripts that initialize critical functionality immediately on load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most common incorrect use:&lt;/strong&gt; Preloading resources for other pages ("I'll preload the dashboard assets on the homepage just in case"). That is what &lt;code&gt;prefetch&lt;/code&gt; is for. Misusing &lt;code&gt;preload&lt;/code&gt; here gives those assets the same priority as critical CSS and can delay the current page's first paint.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prefetch: a future page will probably need this
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prefetch&lt;/code&gt; tells the browser to fetch a resource that a future navigation might need. It is a low-priority request that runs during browser idle time and does not compete with resources the current page needs.&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;!-- Hint that the user will probably navigate to the dashboard next --&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.js"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&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;"prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/dashboard.css"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"style"&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 browser treats &lt;code&gt;prefetch&lt;/code&gt; as a background task. On a busy connection, it may delay the prefetch until the current page is fully loaded. On an idle connection, it may start immediately. The browser makes this decision based on network conditions and its own resource scheduling.&lt;/p&gt;

&lt;p&gt;Prefetched resources are stored in the browser's cache with a short TTL. When the user navigates to the page that needs them, the browser finds them in cache and skips the network request entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The use case for prefetch:&lt;/strong&gt; Predictable navigation paths. You know from analytics that 70% of users who view the homepage proceed to the signup page. Prefetching the signup page's JavaScript and CSS assets during the homepage's idle time means those assets are already cached when the user clicks "Sign Up."&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;!-- On the homepage, prefetch signup page assets --&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;"/signup-bundle.js"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- On product listing pages, prefetch checkout assets --&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;"/checkout-bundle.js"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&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;&lt;strong&gt;When not to use prefetch:&lt;/strong&gt; When you are not confident about the next navigation. Prefetching assets that most users will not need wastes bandwidth, particularly on mobile connections where the user may have limited data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Preconnect: we will talk to this server soon
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;preconnect&lt;/code&gt; tells the browser to establish a connection to an origin before it needs to make any requests to it. Establishing a connection has three steps: DNS lookup, TCP handshake, and TLS negotiation. Together these can take 100 to 500ms depending on the server location and connection quality.&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;!-- Establish connection to CDN before any requests are made to it --&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://cdn.example.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Establish connection to Google Fonts so the font request starts faster --&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;!-- Establish connection to your API server --&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.example.com"&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;&lt;code&gt;preconnect&lt;/code&gt; does not fetch anything. It just performs the handshake. When the actual request to that origin comes (because the browser encountered a font URL in CSS, or your JavaScript fetched from the API), the connection is already established and the request goes straight to data transfer.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;crossorigin&lt;/code&gt; attribute is required for connections to origins that will serve resources requested with CORS (such as fonts). Without &lt;code&gt;crossorigin&lt;/code&gt;, the browser establishes an anonymous connection, but the actual font request is a CORS request that requires credentials, so the browser has to establish a second connection. Two handshakes instead of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The use case for preconnect:&lt;/strong&gt; Any third-party origin that serves resources critical to the current page. Google Fonts, analytics providers, CDN origins for your static assets, API endpoints that your application calls on load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The timing advantage:&lt;/strong&gt; The browser discovers third-party origins by reading your HTML, CSS, and JavaScript. It cannot preconnect until it encounters the URL. With &lt;code&gt;preconnect&lt;/code&gt; in the document head, the connection starts before HTML parsing is even complete. Google's Lighthouse team measured typical savings of 100 to 300ms on FCP for pages with third-party fonts and CDN assets.&lt;/p&gt;




&lt;h2&gt;
  
  
  dns-prefetch: preconnect's less aggressive sibling
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dns-prefetch&lt;/code&gt; does only the DNS lookup, skipping the TCP handshake and TLS negotiation.&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;"dns-prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://api.example.com"&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;Use &lt;code&gt;dns-prefetch&lt;/code&gt; for third-party origins that you know the page will talk to but that are not on the critical path for initial render. The DNS lookup (20 to 120ms) is still saved, but you avoid keeping a TCP connection open for a server the page might not talk to for several seconds.&lt;/p&gt;

&lt;p&gt;For origins that are on the critical path, &lt;code&gt;preconnect&lt;/code&gt; is better. For origins that are used later in the page's lifetime (analytics, chat widgets, lazy-loaded components), &lt;code&gt;dns-prefetch&lt;/code&gt; is the appropriate hint.&lt;/p&gt;




&lt;h2&gt;
  
  
  fetchpriority: priority signaling within a resource type
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fetchpriority&lt;/code&gt; is a newer attribute that works alongside preload, not as a separate hint. It lets you tell the browser the relative priority of resources within the same type.&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;!-- Tell the browser this is the most important image --&amp;gt;&lt;/span&gt;
![Hero](https://renderlog.in/hero.webp)

&lt;span class="c"&gt;&amp;lt;!-- Mark lower-priority images that can wait --&amp;gt;&lt;/span&gt;
![Thumbnail](https://renderlog.in/thumbnail.webp)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;!-- For preloaded LCP images: add fetchpriority to the preload link too --&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;"/hero.webp"&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;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&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;Without &lt;code&gt;fetchpriority&lt;/code&gt;, the browser assigns image priority based on its position in the document and whether it is in the viewport. For the LCP image, the browser often gets this right. But when the LCP image is loaded dynamically via JavaScript, or when it appears below several other images in the HTML, the browser might assign it a lower priority than it deserves. &lt;code&gt;fetchpriority="high"&lt;/code&gt; overrides this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Putting it together: a real page example
&lt;/h2&gt;

&lt;p&gt;Here is what a well-instrumented &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; looks like for a content-heavy site with a hero image, a custom font, and a CDN for assets:&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;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- 1. Preconnect to third-party origins on the critical path --&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="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://cdn.example.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- 2. Preload the LCP image (hero) --&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;"https://cdn.example.com/hero.webp"&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;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;
  &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- 3. Load CSS --&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;"stylesheet"&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"&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;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/styles/main.css"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- 4. Prefetch assets for the most common next page --&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.js"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- 5. DNS prefetch for non-critical third-party 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;"dns-prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.example.com"&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 order matters: preconnects first so connection establishment starts as early as possible, then preloads for current-page critical resources, then the actual &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags, then prefetches for future pages, then dns-prefetch for non-critical origins.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mistake that costs the most
&lt;/h2&gt;

&lt;p&gt;Preloading resources that the page does not use is the most expensive error. The browser fetches the preloaded resource at high priority, competing with your CSS and render-critical JavaScript. If the resource is never used on the current page, Lighthouse will warn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unused preload: /dashboard.js was preloaded but not used within 3 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This unused preload consumed bandwidth and network priority for nothing. It is strictly worse than not having the hint at all.&lt;/p&gt;

&lt;p&gt;Before adding a &lt;code&gt;preload&lt;/code&gt; hint, verify that the resource is used on the current page and that it is on the critical path (meaning the user sees something broken or unstyled without it). If it meets both conditions, preload it. If it is for a future page, use prefetch. If you just need to warm up a connection, use preconnect.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/preload-prefetch-preconnect-resource-hints-guide/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/preload-prefetch-preconnect-resource-hints-guide/&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>browser</category>
      <category>network</category>
      <category>lcp</category>
    </item>
    <item>
      <title>Critical CSS: What Render-Blocking Means and How Inlining Fixes It</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Wed, 13 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/critical-css-what-render-blocking-means-and-how-inlining-fixes-it-2mo</link>
      <guid>https://dev.to/helloashish99/critical-css-what-render-blocking-means-and-how-inlining-fixes-it-2mo</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/browser-main-thread-rendering-pipeline/" rel="noopener noreferrer"&gt;The Browser Main Thread and Rendering Pipeline&lt;/a&gt; explains the full rendering pipeline that critical CSS inlining is optimizing for.&lt;/p&gt;

&lt;p&gt;Lighthouse flags "eliminate render-blocking resources" and most developers look at their CSS files with mild confusion. The file is small. It is on a CDN. How can 15KB of CSS be blocking the render of a page that otherwise looks fast? The answer is in the word "render-blocking" itself, which is more precise than it sounds. The browser will not draw a single pixel to the screen until it has read every CSS file in the document head. Not because it is slow, but because it is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; Why all CSS is render-blocking by specification, what the browser is actually waiting for before painting, how critical CSS inlining removes the wait, and how to extract and inline it without manually editing stylesheets.&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%2Fjy5klnlbh2djphu9gu45.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%2Fjy5klnlbh2djphu9gu45.png" alt="Diagram showing the browser pipeline: HTML parsing stops to download external CSS, blocking paint until the CSSOM is complete. Critical CSS inlining removes the download step from the critical path." width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the browser blocks on CSS
&lt;/h2&gt;

&lt;p&gt;The browser builds two trees before painting: the DOM (Document Object Model) from your HTML, and the CSSOM (CSS Object Model) from your CSS. Rendering requires both. The render tree is built by combining DOM and CSSOM, and nothing is painted until the render tree exists.&lt;/p&gt;

&lt;p&gt;This means CSS is blocking by design. The browser cannot make progress on rendering while waiting for an external CSS file because it literally does not know how to style any element until all CSS is parsed. An element that appears red in the DOM might be styled to be invisible in CSS. The browser has no way to know until it reads the CSS.&lt;/p&gt;

&lt;p&gt;When the browser encounters a &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt; tag in the HTML, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pauses HTML parsing to prioritize fetching the CSS file (CSS is a high-priority resource)&lt;/li&gt;
&lt;li&gt;Starts a network request for the CSS file&lt;/li&gt;
&lt;li&gt;Waits for the full file to download&lt;/li&gt;
&lt;li&gt;Parses the CSS and builds the CSSOM&lt;/li&gt;
&lt;li&gt;Resumes HTML parsing&lt;/li&gt;
&lt;li&gt;Proceeds to build the render tree and paint&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That network request in step 2 is the problem. On a typical server with a 100ms round trip time, a CSS file adds at minimum 100ms to the time before the first pixel appears. With a slower connection or a server that is geographically distant, this can be 300 to 500ms. The CSS file can be completely empty and the delay still happens because the browser does not know that until it receives the empty file.&lt;/p&gt;




&lt;h2&gt;
  
  
  What critical CSS is
&lt;/h2&gt;

&lt;p&gt;Not all CSS needs to block the render. The CSS needed to render the visible content on the initial viewport (above the fold) blocks the render of something the user cares about. The CSS for a modal that appears three screens down, the CSS for the footer, the CSS for components the user has not scrolled to yet: these block rendering but the user does not see the result of that rendering anyway.&lt;/p&gt;

&lt;p&gt;Critical CSS is the subset of your CSS that applies to elements visible in the initial viewport without scrolling. It is the minimum CSS needed to make the above-the-fold content look correct.&lt;/p&gt;

&lt;p&gt;For a typical landing page, critical CSS might include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Body and base typographic styles&lt;/li&gt;
&lt;li&gt;Navigation bar styles&lt;/li&gt;
&lt;li&gt;Hero section layout and colors&lt;/li&gt;
&lt;li&gt;Above-fold image styles&lt;/li&gt;
&lt;li&gt;Any font-face declarations for above-fold text&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is non-critical: sidebar styles, footer, modal, article content styles, form styles for components below the fold.&lt;/p&gt;




&lt;h2&gt;
  
  
  How inlining fixes the problem
&lt;/h2&gt;

&lt;p&gt;Instead of loading critical CSS from an external file, you embed it directly in the HTML document inside a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tag in the &lt;code&gt;&amp;lt;head&amp;gt;&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="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Critical CSS: embedded directly, no network request needed --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;system-ui&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;span class="nc"&gt;.nav&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="m"&gt;60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.nav__logo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80px&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.hero__title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;/* ... rest of above-fold CSS ... */&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Non-critical CSS: loaded asynchronously, does not block paint --&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;"/styles/main.css"&lt;/span&gt;
    &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"style"&lt;/span&gt;
    &lt;span class="na"&gt;onload=&lt;/span&gt;&lt;span class="s"&gt;"this.onload=null;this.rel='stylesheet'"&lt;/span&gt;
  &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;noscript&amp;gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/styles/main.css"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/noscript&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 critical CSS is available immediately: the browser reads the HTML, finds the &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tag, parses the CSS inline, and builds the CSSOM without a network round trip. It can begin rendering above-fold content immediately.&lt;/p&gt;

&lt;p&gt;The full stylesheet loads asynchronously using the &lt;code&gt;rel="preload"&lt;/code&gt; trick (preload the file with &lt;code&gt;as="style"&lt;/code&gt;, then switch &lt;code&gt;rel&lt;/code&gt; to &lt;code&gt;stylesheet&lt;/code&gt; when loaded). Below-fold content that depends on the full stylesheet renders when it loads, but by that time the above-fold content is already visible and the user has started reading.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; fallback handles the case where JavaScript is disabled, which would prevent the &lt;code&gt;onload&lt;/code&gt; from firing. In that scenario, the stylesheet loads normally as a blocking resource.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why manually maintaining critical CSS is impractical
&lt;/h2&gt;

&lt;p&gt;Manually identifying which CSS applies above the fold and which does not is not feasible at any scale. Layouts change. Viewports vary. The above-fold content on a 375px phone is different from the above-fold content on a 1440px monitor.&lt;/p&gt;

&lt;p&gt;The standard approach is to automate extraction with a tool that renders the page in a headless browser, identifies all elements visible in the initial viewport, and extracts the CSS rules that apply to those elements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critters&lt;/strong&gt; is a plugin for webpack and Vite that does this at build time:&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;critters&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 building, Critters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Renders each HTML page in a headless environment&lt;/li&gt;
&lt;li&gt;Identifies which CSS rules apply to above-fold elements&lt;/li&gt;
&lt;li&gt;Inlines those rules in &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tags&lt;/li&gt;
&lt;li&gt;Changes the &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; to load asynchronously&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No manual work required. The build output has correct inlined critical CSS for each page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js&lt;/strong&gt; has had critical CSS extraction built in since v10 through its integration with Critters. If you are using Next.js, this optimization is applied automatically to pages using the App Router.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical&lt;/strong&gt; is a standalone Node.js package for extraction:&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;await&lt;/span&gt; &lt;span class="nx"&gt;critical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.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;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index-critical.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;css&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;critical.css&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Also generate for mobile viewport&lt;/span&gt;
  &lt;span class="na"&gt;dimensions&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;375&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;812&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&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 &lt;code&gt;dimensions&lt;/code&gt; option is important: critical CSS should cover the most common viewport sizes, not just one. A rule that is critical on mobile (because it styles content visible at 375px width) might be non-critical on desktop (where the content is below the fold), and vice versa.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tradeoffs to understand
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HTML file size increases.&lt;/strong&gt; Inlining critical CSS means the CSS is embedded in every HTML response rather than being fetched once and cached. For pages with substantial critical CSS (say, 20KB), this adds 20KB to every HTML response. Weigh this against the round-trip savings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS is duplicated.&lt;/strong&gt; Critical CSS rules appear both in the &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tag and in the full external stylesheet. When the external stylesheet loads, the browser parses the same rules again. This is harmless (CSS parsing is fast) but worth knowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic content complicates extraction.&lt;/strong&gt; If your page uses client-side rendering to insert above-fold content after the initial HTML load, the extraction tool cannot see that content. The critical CSS it extracts will be incomplete because the above-fold elements did not exist when the headless renderer checked.&lt;/p&gt;

&lt;p&gt;For server-side rendered pages, extraction works accurately. For heavily client-side rendered pages, you may need to either use &lt;code&gt;renderBefore&lt;/code&gt; options to delay extraction until after React hydrates, or limit critical CSS to truly static above-fold elements like the navigation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Lighthouse connection
&lt;/h2&gt;

&lt;p&gt;When Lighthouse reports "Eliminate render-blocking resources" and lists CSS files, it is measuring the time between when the page starts loading and when the first paint occurs. Every external CSS file in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; adds to this time.&lt;/p&gt;

&lt;p&gt;After inlining critical CSS and loading the full stylesheet asynchronously, Lighthouse will no longer flag the CSS file as render-blocking because it is loaded asynchronously and does not delay the initial paint.&lt;/p&gt;

&lt;p&gt;The metric that typically improves most is First Contentful Paint, because above-fold content can now paint as soon as the HTML is received rather than waiting for an external CSS round trip. Depending on the server response time and connection speed, the FCP improvement can range from 100ms on fast connections to over 500ms on slow mobile connections.&lt;/p&gt;




&lt;h2&gt;
  
  
  A mental model for understanding render-blocking
&lt;/h2&gt;

&lt;p&gt;Think of the browser as a factory. The HTML is the blueprint, the CSS is the paint colors and finishes specification, and the factory cannot start production until it has both documents. If the paint colors arrive one minute late, the entire factory sits idle for one minute regardless of how fast the machines are.&lt;/p&gt;

&lt;p&gt;Critical CSS inlining is equivalent to printing the paint colors for the first section of the product directly on the blueprint. The factory can start producing the visible parts immediately and wait for the full paint specification to arrive for the parts it will produce later.&lt;/p&gt;

&lt;p&gt;The external stylesheet still arrives and the rest of the page still gets fully styled. But the user sees the first screenful of content without waiting for a network round trip that was never about content the user could see on arrival.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/critical-css-inlining-render-blocking-explained/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/critical-css-inlining-render-blocking-explained/&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>css</category>
      <category>browser</category>
      <category>lcp</category>
    </item>
    <item>
      <title>How HTTP/2 Made Five Frontend Performance Best Practices Obsolete</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Tue, 12 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/how-http2-made-five-frontend-performance-best-practices-obsolete-291p</link>
      <guid>https://dev.to/helloashish99/how-http2-made-five-frontend-performance-best-practices-obsolete-291p</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/network-optimization-spa-react/" rel="noopener noreferrer"&gt;Network Optimization for SPAs and React Apps&lt;/a&gt; covers the modern optimization techniques that work with HTTP/2 rather than around HTTP/1.1's limitations.&lt;/p&gt;

&lt;p&gt;In 2012, a frontend developer optimizing a website's network performance would combine all their JavaScript into one file, combine all their CSS, put icons into a single sprite image, split their static assets across multiple subdomains, and inline small images as base64 strings in CSS. All of these were correct. They were the right things to do.&lt;/p&gt;

&lt;p&gt;By 2020, most of them were wrong. Not slightly suboptimal; actively counterproductive on HTTP/2. The underlying constraint they were designed around had been replaced by a better protocol, and the optimizations built for the old constraint became liabilities under the new one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; What HTTP/1.1's connection limit actually meant in practice, how Google's SPDY experiment led to HTTP/2, what multiplexing changes about the request model, and which of the old practices to stop doing today.&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%2Fi6qxvsbrjhqgr7agyo5z.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%2Fi6qxvsbrjhqgr7agyo5z.png" alt="Diagram comparing HTTP/1.1 sequential request queuing against HTTP/2 multiplexed streams sharing a single connection." width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The HTTP/1.1 problem: six connections, one queue per connection
&lt;/h2&gt;

&lt;p&gt;HTTP/1.1 browsers open at most 6 parallel TCP connections to a single domain. Each connection handles one request at a time. If a response is slow, everything queued behind it on that connection waits.&lt;/p&gt;

&lt;p&gt;A page loading 30 resources (CSS, JavaScript, fonts, images) from the same origin has to queue 24 of those resources to wait while the first 6 load. When one finishes, the next one in the queue starts. The 30th resource does not begin loading until 24 other resources have each had their turn.&lt;/p&gt;

&lt;p&gt;The practical consequence was measured in seconds on typical web pages of that era. Performance engineers spent significant effort on strategies to work around this constraint.&lt;/p&gt;




&lt;h2&gt;
  
  
  The five workarounds that HTTP/1.1 made necessary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Domain sharding.&lt;/strong&gt; Since browsers limited connections to 6 per domain, the workaround was to host assets on multiple subdomains. &lt;code&gt;static1.example.com&lt;/code&gt;, &lt;code&gt;static2.example.com&lt;/code&gt;, &lt;code&gt;static3.example.com&lt;/code&gt;. Each subdomain got its own 6 connections, effectively multiplying the connection limit by the number of shards.&lt;/p&gt;

&lt;p&gt;This worked at the cost of extra DNS lookups (20-120ms each), extra TCP handshakes, and extra TLS negotiations for each new domain. The parallelism gain typically exceeded those costs under HTTP/1.1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. CSS sprites.&lt;/strong&gt; Making a separate HTTP request for each small icon or UI image was expensive under HTTP/1.1 because each request occupied a connection slot for its full round trip. The solution was to combine all icons into a single large image (the sprite sheet) and use CSS &lt;code&gt;background-position&lt;/code&gt; to display the relevant portion of the sprite for each icon.&lt;/p&gt;

&lt;p&gt;This reduced dozens of requests to one. The downside was that the browser had to download the entire sprite sheet even if only one icon was used on a given page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. JavaScript and CSS concatenation.&lt;/strong&gt; For the same reason, all JavaScript files were concatenated into one file, and all CSS files into another. Two HTTP requests instead of twenty. The cost: browser cache granularity. If any file changed, the entire concatenated bundle was invalidated, even if only one module changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Inlining small resources as base64.&lt;/strong&gt; Images smaller than a few kilobytes were often embedded directly in CSS as base64-encoded strings. This eliminated the HTTP request entirely at the cost of increasing the CSS file size and preventing the image from being cached separately.&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="c"&gt;/* HTTP/1.1 era optimization: inline small images to save a request */&lt;/span&gt;
&lt;span class="nc"&gt;.icon-search&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA...')&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;5. Cookie-free domains for static assets.&lt;/strong&gt; Browsers send all cookies for a domain with every request to that domain. For static assets like images, fonts, and JavaScript, cookies are irrelevant but they still add bytes to every request header. Serving static assets from a cookieless domain avoided this overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Google built instead: SPDY
&lt;/h2&gt;

&lt;p&gt;In 2009, Google's Chrome and infrastructure teams began prototyping a new transport protocol called SPDY (pronounced "speedy"). The goal was to eliminate the performance bottlenecks in HTTP/1.1 at the protocol level rather than working around them with application-level hacks.&lt;/p&gt;

&lt;p&gt;SPDY ran over a single TCP connection and used multiplexing: multiple requests could share the same connection simultaneously, with responses interleaved in priority order. There was no queue. A slow response did not block faster responses. The connection limit was no longer the bottleneck.&lt;/p&gt;

&lt;p&gt;Google deployed SPDY across their own infrastructure and published the results. Sites serving Google traffic saw measurable page load improvements. The IETF used SPDY as the basis for HTTP/2, which was standardized in 2015. Chrome, Firefox, and all major servers adopted it rapidly.&lt;/p&gt;

&lt;p&gt;By 2018, HTTP/2 support was widespread enough that most web traffic ran over it. By 2022, HTTP/2 was the norm and HTTP/1.1 was the exception for major sites.&lt;/p&gt;




&lt;h2&gt;
  
  
  What multiplexing actually means
&lt;/h2&gt;

&lt;p&gt;HTTP/2 multiplexing means that multiple HTTP requests and responses are interleaved on a single TCP connection. Each request/response pair is broken into frames, and frames from different requests can be intermixed on the wire.&lt;/p&gt;

&lt;p&gt;From the browser's perspective: it can make 50 requests simultaneously over one connection and receive responses as they become ready, regardless of the order requests were made. A fast response to request 30 can arrive before the slow response to request 1.&lt;/p&gt;

&lt;p&gt;From the server's perspective: it sees all requests and can prioritize responses. CSS and critical JavaScript can be sent before images, regardless of which was requested first.&lt;/p&gt;

&lt;p&gt;The 6-connection limit no longer constrains parallelism. The browser can have hundreds of in-flight requests over a single HTTP/2 connection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the old optimizations became anti-patterns
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Domain sharding now hurts performance.&lt;/strong&gt; With HTTP/2, all assets should come from the same origin. Multiple origins require multiple connections, and each HTTP/2 connection has overhead: TCP handshake, TLS negotiation, and the HTTP/2 settings exchange. Where HTTP/1.1 made multiple connections valuable, HTTP/2 makes a single connection more efficient.&lt;/p&gt;

&lt;p&gt;Sharding across subdomains today means the browser establishes multiple connections when one would serve all resources with better prioritization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS sprites now cost more than they save.&lt;/strong&gt; HTTP/2 handles many small requests efficiently because they share a connection without queuing behind each other. A sprite sheet that was one efficient request is now one large resource that the browser must download entirely before any icon renders. Individual SVG files can be requested in parallel and only the ones used on the current page need to be downloaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concatenated bundles hurt cache efficiency.&lt;/strong&gt; The original reason for concatenation was to minimize HTTP requests. Under HTTP/2, 30 small JavaScript modules load nearly as fast as one large concatenated file. But the 30 individual files can be cached independently. When you change one module, only that module's cache is invalidated. With concatenation, every change invalidates the entire bundle.&lt;/p&gt;

&lt;p&gt;This is one of the reasons Vite's development server can serve unbundled ES modules efficiently: in development over HTTP/2, hundreds of individual module requests perform similarly to serving one large bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inlining base64 images inflates critical resources.&lt;/strong&gt; A base64-encoded image in your CSS increases CSS file size and delays rendering of everything that depends on that CSS file. Under HTTP/2, the marginal cost of an extra image request is low enough that it is almost never worth paying the inlining cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cookie-free domains fragment the connection.&lt;/strong&gt; Serving assets from multiple origins forces multiple HTTP/2 connections. The cookie overhead that domain separation was designed to avoid is minor compared to the connection overhead of the separation itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  What has replaced the old optimizations
&lt;/h2&gt;

&lt;p&gt;The modern equivalents address the same performance concerns through different mechanisms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Old optimization&lt;/th&gt;
&lt;th&gt;Modern replacement&lt;/th&gt;
&lt;th&gt;Why it changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Domain sharding&lt;/td&gt;
&lt;td&gt;Single origin, HTTP/2&lt;/td&gt;
&lt;td&gt;HTTP/2 connection is more efficient than multiple HTTP/1.1 connections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS sprites&lt;/td&gt;
&lt;td&gt;Inline SVG, icon fonts, HTTP/2 parallel requests&lt;/td&gt;
&lt;td&gt;Small requests are cheap; sprites prevent caching granularity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS/CSS concatenation&lt;/td&gt;
&lt;td&gt;Code splitting with long-lived vendor chunks&lt;/td&gt;
&lt;td&gt;Cache efficiency matters more than request count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base64 inlining&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;preload&lt;/code&gt; link hints, &lt;code&gt;fetchpriority="high"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Control load priority without embedding in CSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie-free domains&lt;/td&gt;
&lt;td&gt;Partitioned cookies, CDN configuration&lt;/td&gt;
&lt;td&gt;Modern cookie scoping reduces cross-domain request overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is consistent: the old optimizations were workarounds for the 6-connection limit. The modern replacements assume multiplexing and optimize for what HTTP/2 actually costs: connection establishment, request priority signaling, and cache efficiency.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to check if your site uses HTTP/2
&lt;/h2&gt;

&lt;p&gt;Open Chrome DevTools, go to the Network tab, right-click the column headers, and enable the "Protocol" column. Reload the page. Each resource will show its protocol: &lt;code&gt;h2&lt;/code&gt; for HTTP/2, &lt;code&gt;h3&lt;/code&gt; for HTTP/3, or &lt;code&gt;http/1.1&lt;/code&gt; for the older protocol.&lt;/p&gt;

&lt;p&gt;If any of your own resources show &lt;code&gt;http/1.1&lt;/code&gt; while others show &lt;code&gt;h2&lt;/code&gt;, something in your infrastructure is not upgraded. Common causes: an old reverse proxy in front of your server, a CDN that does not have HTTP/2 enabled for your origin, or an older self-hosted server configuration.&lt;/p&gt;

&lt;p&gt;If all resources show &lt;code&gt;h2&lt;/code&gt;, the old workarounds above should be removed from your build process if they are still present. They are doing nothing useful and in several cases are actively reducing performance.&lt;/p&gt;




&lt;h2&gt;
  
  
  One thing that did not change
&lt;/h2&gt;

&lt;p&gt;Request count is less important under HTTP/2, but response size still matters. Every byte downloaded costs bandwidth and parse time. The goal of reducing unnecessary requests shifted to reducing unnecessary bytes. Code splitting, tree shaking, image compression, and font subsetting are all about bytes, not request count, and they matter as much under HTTP/2 as they did under HTTP/1.1.&lt;/p&gt;

&lt;p&gt;The optimizations that survived the HTTP/2 transition are the ones that were always about bytes. The ones that disappeared were the ones that were only about request count.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/http2-multiplexing-frontend-performance-changes/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/http2-multiplexing-frontend-performance-changes/&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>browser</category>
      <category>javascript</category>
    </item>
    <item>
      <title>React.lazy() Route Code Splitting Explained</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Mon, 11 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/reactlazy-route-code-splitting-explained-128d</link>
      <guid>https://dev.to/helloashish99/reactlazy-route-code-splitting-explained-128d</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/build-bundles-treeshaking-code-splitting/" rel="noopener noreferrer"&gt;JavaScript Bundle Analysis: Tree Shaking and Code Splitting Explained&lt;/a&gt; covers how bundlers construct module graphs and where tree shaking fails, which is the foundation for understanding what code splitting actually does.&lt;/p&gt;

&lt;p&gt;The size of your JavaScript bundle is not the number that matters for first load. What matters is how much of that bundle is needed to render the page the user actually landed on. A 5MB React application where the user landed on the marketing homepage might need 80KB to show that page. The other 4.92MB is code for routes the user has not visited and may never visit. &lt;code&gt;React.lazy()&lt;/code&gt; is how you stop shipping those 4.92MB on first load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; What &lt;code&gt;React.lazy()&lt;/code&gt; does to the bundle at build time, how Suspense fits into the loading flow, how to confirm the split actually happened, the cases where it silently fails, and the preloading pattern that prevents waterfall fetches on navigation.&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%2Fce50edwubauj92yy2nim.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%2Fce50edwubauj92yy2nim.png" alt="Diagram showing a single large bundle being split into a small initial chunk and separate route chunks that load on demand when the user navigates." width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What React.lazy() does to the bundle
&lt;/h2&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 key word is dynamic. A static import at the top of a file is analyzed at build time and the imported module is included in the same output chunk. A dynamic import is a split point: the bundler creates a separate output chunk containing the dynamically imported module and all of its unique dependencies.&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;// Static import: Dashboard ends up in the same chunk as App&lt;/span&gt;

&lt;span class="c1"&gt;// Dynamic import with React.lazy(): Dashboard becomes a separate chunk&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;./pages/Dashboard&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;When you switch from a static import to &lt;code&gt;React.lazy()&lt;/code&gt;, your bundler (Vite, Webpack, or otherwise) creates a new file in the build output. Instead of one bundle, you get the main bundle plus a chunk file specifically for Dashboard and everything it imports that is not already in the main bundle.&lt;/p&gt;

&lt;p&gt;The main bundle shrinks by the size of Dashboard and its unique dependencies. The Dashboard chunk is downloaded on demand when the user navigates to the dashboard route.&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;Home&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;./pages/Home&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;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;./pages/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;Settings&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;./pages/Settings&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;./pages/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;In this setup, a user who lands on &lt;code&gt;/&lt;/code&gt; downloads the main bundle plus the Home chunk. They do not download anything for Dashboard, Settings, or AdminPanel until they navigate to those routes.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Suspense fits in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;React.lazy()&lt;/code&gt; by itself will throw an error if you try to render the component before its chunk has loaded. Suspense catches that error and shows a fallback UI while the chunk downloads.&lt;/p&gt;

&lt;p&gt;The mechanics: when React tries to render a lazy component and the chunk is not yet loaded, the component throws a Promise. Suspense catches the thrown Promise and renders the fallback. When the Promise resolves (the chunk has downloaded), Suspense re-renders the tree with the actual component.&lt;/p&gt;

&lt;p&gt;This is the same Suspense boundary you would use for data fetching. The behavior is identical: throw a Promise to signal "not ready," catch it at the nearest Suspense boundary, show fallback while waiting, render real content when ready.&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;// Suspense can wrap individual routes or the entire app&lt;/span&gt;
&lt;span class="c1"&gt;// Wrapping the whole app is simpler; wrapping per-route allows per-route fallbacks&lt;/span&gt;

&lt;span class="c1"&gt;// Per-route fallbacks (more control):&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="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;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;
        }
      /&amp;gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Routes&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;For most applications, a single Suspense boundary around all routes is simpler and sufficient. The fallback can be a generic page loader or even null if you prefer a blank transition. The key constraint is that the Suspense boundary must be an ancestor of the lazy component in the React tree.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verifying the split actually happened
&lt;/h2&gt;

&lt;p&gt;The most common mistake with code splitting is thinking you have done it when you have not. Before assuming a &lt;code&gt;React.lazy()&lt;/code&gt; conversion worked, verify it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method 1: Check the build output.&lt;/strong&gt; Run &lt;code&gt;npm run build&lt;/code&gt; and look at the output files. A successful route split produces separate chunk files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/assets/index-a1b2c3.js       (main bundle, ~80KB)
dist/assets/Dashboard-d4e5f6.js   (dashboard chunk, ~45KB)
dist/assets/Settings-g7h8i9.js    (settings chunk, ~22KB)
dist/assets/AdminPanel-j0k1l2.js  (admin chunk, ~38KB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see only one JavaScript file or if all your route components appear in &lt;code&gt;index.js&lt;/code&gt;, the split did not happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method 2: Use bundle analysis.&lt;/strong&gt; With Vite, add the visualizer plugin:&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="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 building, open &lt;code&gt;bundle-stats.html&lt;/code&gt;. If the split worked, you will see your route components in separate rectangles outside the main bundle rectangle. If they are inside the main bundle, something went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method 3: Check the Network tab.&lt;/strong&gt; Open Chrome DevTools, go to the Network tab, filter by JS, and navigate between routes. When you navigate to a route for the first time, you should see a new JS file appear in the network requests. If no new files appear, the code was included in the initial bundle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the split silently fails
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Static imports elsewhere in the file.&lt;/strong&gt; If you import a component from a file that is also imported statically somewhere in the main bundle, the component will be included in the main bundle regardless of your &lt;code&gt;lazy()&lt;/code&gt; call. The bundler deduplicates modules, so a module that is reachable through a static import path will not be split out.&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 file statically imports Dashboard, so Dashboard is in the main bundle&lt;/span&gt;

&lt;span class="c1"&gt;// This lazy() call does nothing useful because Dashboard is already included&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DashboardLazy&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;./pages/Dashboard&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;Check that the component you are splitting is not imported anywhere else in the main bundle's dependency chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eager imports in the entry point.&lt;/strong&gt; The entry point (usually &lt;code&gt;main.tsx&lt;/code&gt; or &lt;code&gt;App.tsx&lt;/code&gt;) must not have static imports for the components you want to split.&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;// main.tsx: these static imports pull everything into the initial bundle&lt;/span&gt;

&lt;span class="c1"&gt;// All of these are now in the initial bundle regardless of lazy() usage below&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your entry file imports these components, remove those imports and rely entirely on &lt;code&gt;React.lazy()&lt;/code&gt; for the route components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared dependencies.&lt;/strong&gt; If Dashboard and Settings both import a large shared library, that library goes into the main chunk (or a separate vendor chunk) rather than being duplicated in each route chunk. This is correct behavior and not a failure. The route chunks will be smaller than you expect because shared code is extracted automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Named chunks for production debugging
&lt;/h2&gt;

&lt;p&gt;By default, bundlers generate hash-based chunk names like &lt;code&gt;chunk-d4e5f6.js&lt;/code&gt;. These are stable for caching but unreadable in production error logs.&lt;/p&gt;

&lt;p&gt;Add a magic comment to name chunks:&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;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="cm"&gt;/* webpackChunkName: "dashboard" */&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pages/Dashboard&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;// In Vite, use vitePreload (Vite handles naming automatically based on file names in most cases)&lt;/span&gt;
&lt;span class="c1"&gt;// But you can also add rollupOptions in vite.config.ts to control chunk naming&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With named chunks, your build output becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/assets/dashboard-d4e5f6.js
dist/assets/settings-g7h8i9.js
dist/assets/admin-panel-j0k1l2.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production stack traces and network logs become readable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Preloading chunks on hover to eliminate waterfall
&lt;/h2&gt;

&lt;p&gt;The biggest UX issue with lazy loading is the delay when a user first navigates to a route. The sequence is: user clicks link, React renders null (Suspense fallback), browser fetches chunk, chunk executes, component renders. That network fetch adds latency.&lt;/p&gt;

&lt;p&gt;The standard fix is to preload the chunk before the user clicks, triggered on hover or focus:&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;// Preload helper&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preloadComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;factory&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;Component&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="nx"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Trigger the import() immediately (while hovering)&lt;/span&gt;
  &lt;span class="c1"&gt;// React.lazy caches the result, so when Suspense renders it, the chunk is ready&lt;/span&gt;
  &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;factory&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;Component&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;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;./pages/Dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// In your nav link:&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;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;navigate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useNavigate&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;handleMouseEnter&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="c1"&gt;// Trigger the chunk fetch when user hovers the link&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;./pages/Dashboard&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;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;a&lt;/span&gt;
      &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onMouseEnter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleMouseEnter&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onFocus&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleMouseEnter&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="nt"&gt;a&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;When the user hovers a navigation link, the chunk download starts. By the time they click and React tries to render the route, the chunk is already in the browser's cache. The Suspense fallback either never shows or shows for under 100ms.&lt;/p&gt;

&lt;p&gt;React Router v6 handles this through the &lt;code&gt;loader&lt;/code&gt; pattern in the data router API. Next.js has built-in prefetching for &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt; components. For custom setups, the hover-triggered import approach above covers most cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers in practice
&lt;/h2&gt;

&lt;p&gt;Here is a realistic example from a mid-size React application before and after route splitting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Before (included in initial bundle)&lt;/th&gt;
&lt;th&gt;After (separate chunk, loaded on demand)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Homepage&lt;/td&gt;
&lt;td&gt;1.2MB total initial JS&lt;/td&gt;
&lt;td&gt;85KB initial bundle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;(already loaded)&lt;/td&gt;
&lt;td&gt;245KB, loads when user navigates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settings&lt;/td&gt;
&lt;td&gt;(already loaded)&lt;/td&gt;
&lt;td&gt;78KB, loads when user navigates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin Panel&lt;/td&gt;
&lt;td&gt;(already loaded)&lt;/td&gt;
&lt;td&gt;312KB, loads when user navigates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reports&lt;/td&gt;
&lt;td&gt;(already loaded)&lt;/td&gt;
&lt;td&gt;168KB, loads when user navigates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A user who only uses the homepage never downloads the Dashboard chunk. A user who does use the dashboard downloads 245KB for it specifically, rather than getting it bundled with everything else. The browser can also cache each chunk separately, so after a first visit to the dashboard, subsequent visits serve it from cache regardless of whether the main bundle changed.&lt;/p&gt;

&lt;p&gt;The improvement in Time to Interactive for first-time visitors is proportional to how much of the pre-split bundle was route-specific code. In applications with many routes and large per-route dependencies, the improvement can be dramatic. In small applications with 3 routes and shared components, the split saves less. Measure your bundle before and after to know if the split is worth the added complexity.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/react-lazy-route-code-splitting-guide/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/react-lazy-route-code-splitting-guide/&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>react</category>
      <category>bundling</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Skeleton Screens vs Spinners: Perceived Load Time</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sun, 10 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/skeleton-screens-vs-spinners-perceived-load-time-1nlj</link>
      <guid>https://dev.to/helloashish99/skeleton-screens-vs-spinners-perceived-load-time-1nlj</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/core-web-vitals-lighthouse-explained/" rel="noopener noreferrer"&gt;Core Web Vitals and Lighthouse Explained&lt;/a&gt; covers how Google measures performance in the field, including how perceived performance metrics like INP differ from load metrics.&lt;/p&gt;

&lt;p&gt;Two pages load in exactly the same amount of time. One shows a spinner. The other shows a skeleton screen. Users consistently rate the skeleton screen page as faster, even when it is not. This is not a quirk or a fluke. LinkedIn documented it, researchers replicated it, and it has a straightforward neurological explanation. Understanding why it works also explains when it does not, and why getting this wrong can make a genuinely fast page feel slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; Why spinners and skeleton screens produce different perceived wait times at identical actual load times, what LinkedIn's research found, when to use each pattern, and a CSS-only skeleton implementation that does not require a library.&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%2Fbucnralve0tzkuujrjg7.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%2Fbucnralve0tzkuujrjg7.png" alt="Side-by-side comparison of a spinner loading state versus a skeleton screen loading state, showing how the skeleton communicates layout structure before content arrives." width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The LinkedIn research
&lt;/h2&gt;

&lt;p&gt;In 2013, LinkedIn redesigned their mobile apps and faced a choice about loading states. Their design team, led by Luke Wroblewski who later wrote about this extensively, ran studies comparing spinner-based loading with skeleton screens.&lt;/p&gt;

&lt;p&gt;The finding: users perceived pages with skeleton screens as loading faster than identical pages with spinners, even when measured load time was exactly the same.&lt;/p&gt;

&lt;p&gt;The explanation comes from cognitive psychology. A spinner communicates "something is happening, wait." It gives no information about what is coming. The brain is in an open-ended wait state, which feels longer than a wait with a defined endpoint.&lt;/p&gt;

&lt;p&gt;A skeleton screen communicates structure. The brain sees a page layout without content and begins filling in predictions about what will appear. By the time the content arrives, the brain has already partially processed the layout. The content arrival feels more like "confirmation" than "loading complete." The subjective experience of waiting is compressed.&lt;/p&gt;

&lt;p&gt;This is the same principle that makes progress bars feel faster than indefinite spinners, and why seeing "2 minutes remaining" on a file download feels better than "calculating..." even though knowing the duration does not change it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the brain actually does during a skeleton screen
&lt;/h2&gt;

&lt;p&gt;When users see a skeleton screen, they are not passively waiting. They are pattern-matching.&lt;/p&gt;

&lt;p&gt;The brain's visual system recognizes layout shapes before it recognizes content. When you see gray rectangles in the shape of a card with a title area and a thumbnail placeholder, you already know you are looking at a list of articles or products. Your attention moves to where the interesting content will appear. When the content loads, your eye is already in position.&lt;/p&gt;

&lt;p&gt;With a spinner, there is nothing to pattern-match against. No layout information has arrived. The entire page is an unknown. The user's attention has nowhere to go.&lt;/p&gt;

&lt;p&gt;The practical result: skeleton screens reduce the cognitive cost of waiting, even when they do not reduce the actual wait. The user feels more in control of the interaction, and perceived control reduces perceived wait time.&lt;/p&gt;




&lt;h2&gt;
  
  
  When spinners are correct
&lt;/h2&gt;

&lt;p&gt;Skeleton screens are not always better. There are situations where a spinner is the right choice and a skeleton screen would be wrong or even counterproductive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short waits under 300ms.&lt;/strong&gt; Skeleton screens require the user to notice the skeleton, process it, and then see it replaced by content. For operations that complete very quickly, this flicker of skeleton-then-content is more disruptive than a brief spinner, or nothing at all. The general rule is that anything under 300ms should show no loading state, anything between 300ms and 1 second can use a skeleton, and only operations that reliably take more than 1 second benefit clearly from the skeleton pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unpredictable content shapes.&lt;/strong&gt; Skeleton screens only work when you know roughly what the loaded content will look like. If the content shape depends on the data (for example, a query that might return a single result or a table of 50 results), you cannot sketch a meaningful skeleton. A generic gray rectangle that looks nothing like the eventual content provides no layout preview benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actions with binary outcomes.&lt;/strong&gt; For a form submission that either succeeds or shows an error, a skeleton screen is misleading. The user does not want a preview of the success state while waiting for confirmation that the action worked. A spinner correctly communicates "outcome is pending."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Destructive or confirmation operations.&lt;/strong&gt; "Deleting..." should show a spinner. Showing a skeleton of the content-less state while a delete processes would be confusing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three-tier timing model
&lt;/h2&gt;

&lt;p&gt;A practical framework for choosing loading states based on expected wait time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wait time&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Under 100ms&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Response feels instant, loading state adds flicker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100ms to 300ms&lt;/td&gt;
&lt;td&gt;Optional subtle fade&lt;/td&gt;
&lt;td&gt;May be perceived as lag; brief indicator acceptable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300ms to 1000ms&lt;/td&gt;
&lt;td&gt;Skeleton screen&lt;/td&gt;
&lt;td&gt;Long enough to notice; layout preview reduces anxiety&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over 1000ms&lt;/td&gt;
&lt;td&gt;Skeleton screen with progress hint&lt;/td&gt;
&lt;td&gt;User needs reassurance that something is happening&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indeterminate long wait&lt;/td&gt;
&lt;td&gt;Progress bar or spinner with status text&lt;/td&gt;
&lt;td&gt;When skeleton does not map to the content shape&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key insight is that loading states are not about communicating "loading." They are about managing user anxiety during a gap. Skeleton screens manage anxiety by filling the gap with useful information. Spinners manage anxiety by confirming that the system is working. Both are valid anxiety-management tools, just for different scenarios.&lt;/p&gt;




&lt;h2&gt;
  
  
  A CSS-only skeleton implementation
&lt;/h2&gt;

&lt;p&gt;Skeleton screens do not require a library. A pulsing animation with CSS is sufficient for most use cases.&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="c"&gt;/* Base skeleton styles */&lt;/span&gt;
&lt;span class="nc"&gt;.skeleton&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e2e8f0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* The shimmer animation */&lt;/span&gt;
&lt;span class="nc"&gt;.skeleton&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="m"&gt;90deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;transparent&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;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shimmer&lt;/span&gt; &lt;span class="m"&gt;1.5s&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateX&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;shimmer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateX&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="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 html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Article card skeleton --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skeleton-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: 100%; height: 200px; margin-bottom: 12px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: 80%; height: 20px; margin-bottom: 8px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: 60%; height: 16px; margin-bottom: 8px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width: 40%; height: 14px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shimmer effect (the light that sweeps across the skeleton) serves a purpose beyond aesthetics. It signals motion, which confirms to the user that the page is alive and loading. A static gray block with no animation is harder to distinguish from a broken or empty state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Skeleton screens in React
&lt;/h2&gt;

&lt;p&gt;In a React application, skeleton screens are typically implemented as separate components that match the shape of the loaded content.&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;// The loaded state&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ArticleCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;article&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;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&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;h2&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&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;h2&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;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="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;excerpt&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readingTime&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; min read&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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;span class="c1"&gt;// The skeleton state -- same layout structure, no real content&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ArticleCardSkeleton&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;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&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="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;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;80%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;24px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12px&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="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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;16px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8px&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="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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;40%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;8px&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="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;span class="c1"&gt;// The parent component that chooses which to render&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ArticleFeed&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="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useArticles&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;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"feed"&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;isLoading&lt;/span&gt;
        &lt;span class="p"&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="na"&gt;length&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;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 skeleton component mirrors the layout structure of the real component. The closer the skeleton matches the eventual content shape, the stronger the cognitive priming effect. A skeleton that looks nothing like the content (generic gray blocks with wrong proportions) provides little benefit over a spinner.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mistake that makes skeletons worse than spinners
&lt;/h2&gt;

&lt;p&gt;The biggest failure mode for skeleton screens is showing them when the data loads fast enough that the skeleton is visible for under 200ms. The sequence becomes: blank page, skeleton flash, content. That flash is worse than a spinner because it represents unnecessary visual complexity.&lt;/p&gt;

&lt;p&gt;The fix is a minimum display time or a delayed 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;ArticleFeed&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="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useArticles&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;showSkeleton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setShowSkeleton&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="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="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Only show skeleton if loading takes more than 300ms&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&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="nf"&gt;setShowSkeleton&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="mi"&gt;300&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;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setShowSkeleton&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="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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;showSkeleton&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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="nx"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;showSkeleton&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="nx"&gt;data&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;article&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 this approach, fast data loads (under 300ms) show nothing and content appears cleanly. Slow loads show the skeleton after the threshold. Users never see the flash.&lt;/p&gt;




&lt;h2&gt;
  
  
  Perceived performance is real performance
&lt;/h2&gt;

&lt;p&gt;Lighthouse scores and Core Web Vitals measure objective timing. They matter and correlate with user satisfaction at scale. But the experience of waiting is subjective, and subjective experience is what determines whether a user comes back.&lt;/p&gt;

&lt;p&gt;A page with a 2 second LCP and a well-implemented skeleton can feel faster than a page with a 1.5 second LCP and a spinner, if the skeleton communicates layout structure clearly and the content arrival matches the preview. That gap between measured and perceived performance is where skeleton screens operate.&lt;/p&gt;

&lt;p&gt;LinkedIn bet their mobile redesign on this insight. The research held up. The pattern has been standard practice at every major product company since.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/skeleton-screens-vs-spinners-perceived-performance/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/skeleton-screens-vs-spinners-perceived-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>frontend</category>
      <category>ux</category>
      <category>react</category>
    </item>
    <item>
      <title>srcset and sizes: How the Browser Picks Images</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Sat, 09 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/srcset-and-sizes-how-the-browser-picks-images-353k</link>
      <guid>https://dev.to/helloashish99/srcset-and-sizes-how-the-browser-picks-images-353k</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/loading-lazy-html-attribute-how-it-works/" rel="noopener noreferrer"&gt;What loading="lazy" Does and Why Google Added It to the HTML Spec&lt;/a&gt; covers the other half of image loading behavior that pairs with responsive image selection.&lt;/p&gt;

&lt;p&gt;A user on a 375px iPhone with a 3x retina display viewing your site on 4G should get a different image file than a user on a 1440px desktop with a 1x monitor on a slow hotel WiFi connection. These two users have completely different needs: one wants a sharp image at a small size, the other wants a smaller file at a large size. Without &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt;, they both get the same file, and at least one of them is getting it wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; Why a single image URL cannot serve all devices correctly, what the &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; attributes actually tell the browser, how the browser's selection algorithm works, and the common mistakes that make the whole system silently fail.&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%2Fpj3r5znq7il7r80ey880.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%2Fpj3r5znq7il7r80ey880.png" alt="Diagram showing how the browser uses srcset and sizes to select the optimal image based on viewport width and device pixel ratio." width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with a single image URL
&lt;/h2&gt;

&lt;p&gt;When you write &lt;code&gt;&amp;lt;img src="/hero.jpg"&amp;gt;&lt;/code&gt;, every visitor to your page downloads the same file regardless of their device, screen size, or connection speed.&lt;/p&gt;

&lt;p&gt;For a full-bleed hero image that looks good on a 4K monitor, you might need a 2400px wide image. On a 375px phone, that same image takes four times more bandwidth to download than needed. The phone then scales it down to 375px, discarding all that extra data. You paid for data that was thrown away.&lt;/p&gt;

&lt;p&gt;The inverse is also a problem. If you serve a small image to save bandwidth, it looks blurry on high-density displays like Retina screens because those screens pack 2x or 3x more pixels into the same physical space and need a proportionally larger image to fill them sharply.&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;!-- Every device gets the same 2400px image. Wrong. --&amp;gt;&lt;/span&gt;
![Hero image](https://renderlog.in/hero-2400.jpg)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The solution is not to pick one compromise image. The solution is to tell the browser about all your image options and let it pick the right one based on information it already has: the viewport size, the device pixel ratio, and the connection speed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What srcset actually tells the browser
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;srcset&lt;/code&gt; attribute is a comma-separated list of image candidates. Each candidate has a URL and a &lt;strong&gt;width descriptor&lt;/strong&gt; that tells the browser how wide that image file is in pixels.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;w&lt;/code&gt; unit after each number is the intrinsic width of that image file. It is not the display width. It is saying: "this file, when you open it, is 800 pixels wide." That is a fact about the file, not an instruction to the browser.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;src&lt;/code&gt; attribute is the fallback for browsers that do not understand &lt;code&gt;srcset&lt;/code&gt;. Old browsers ignore &lt;code&gt;srcset&lt;/code&gt; and use &lt;code&gt;src&lt;/code&gt; instead. Always include it.&lt;/p&gt;

&lt;p&gt;Without a &lt;code&gt;sizes&lt;/code&gt; attribute, the browser assumes the image will be displayed at 100% of the viewport width. That assumption is wrong for almost every real layout, which is why &lt;code&gt;sizes&lt;/code&gt; exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  What sizes tells the browser
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sizes&lt;/code&gt; tells the browser how wide the image will actually appear on screen at different viewport sizes. It uses the same media query syntax as CSS.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Reading the &lt;code&gt;sizes&lt;/code&gt; value here: if the viewport is 768px or narrower, the image takes up 100% of the viewport width. If the viewport is between 768px and 1200px, the image takes up 50% of the viewport width. Otherwise, the image is exactly 1200px wide (capped by a max-width container).&lt;/p&gt;

&lt;p&gt;The browser reads &lt;code&gt;sizes&lt;/code&gt; before it has applied any CSS. This is intentional. The browser preloader runs very early in the parsing process, before stylesheets are downloaded, to get image requests into the network queue as soon as possible. It cannot wait for CSS to be parsed to know image display sizes. So you, the developer, tell it the answer ahead of time through &lt;code&gt;sizes&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The selection algorithm
&lt;/h2&gt;

&lt;p&gt;Given &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt;, the browser calculates which image to fetch like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Evaluate the &lt;code&gt;sizes&lt;/code&gt; conditions against the current viewport width to get the &lt;strong&gt;rendered width&lt;/strong&gt; (call it W)&lt;/li&gt;
&lt;li&gt;Multiply W by the &lt;strong&gt;device pixel ratio&lt;/strong&gt; (DPR) to get the &lt;strong&gt;needed pixels&lt;/strong&gt; (call it P)&lt;/li&gt;
&lt;li&gt;Pick the smallest image from &lt;code&gt;srcset&lt;/code&gt; that is at least P pixels wide&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a concrete example: viewport is 375px wide, DPR is 3x, &lt;code&gt;sizes&lt;/code&gt; says &lt;code&gt;100vw&lt;/code&gt; at this breakpoint.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rendered width W = 375px&lt;/li&gt;
&lt;li&gt;Needed pixels P = 375 x 3 = 1125px&lt;/li&gt;
&lt;li&gt;Available candidates: 400w, 800w, 1200w, 2400w&lt;/li&gt;
&lt;li&gt;Smallest that is at least 1125px wide: 1200w&lt;/li&gt;
&lt;li&gt;Browser fetches &lt;code&gt;/hero-1200.webp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt;, that same device would fetch &lt;code&gt;/hero-2400.webp&lt;/code&gt;, downloading 4x more data than it needs to render a sharp image.&lt;/p&gt;

&lt;p&gt;On a 1440px desktop with 1x DPR where the image appears at 50% viewport width:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rendered width W = 720px&lt;/li&gt;
&lt;li&gt;Needed pixels P = 720 x 1 = 720px&lt;/li&gt;
&lt;li&gt;Smallest candidate at least 720px: 800w&lt;/li&gt;
&lt;li&gt;Browser fetches &lt;code&gt;/hero-800.webp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A desktop user on a 1x monitor is downloading a smaller file than an iPhone user, because the desktop user does not need the extra pixels that a retina display requires.&lt;/p&gt;




&lt;h2&gt;
  
  
  The picture element for format switching
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;srcset&lt;/code&gt; on a plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; handles size selection but not format selection. For that, you use the &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element, which lets you offer different formats with explicit priority order.&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;type=&lt;/span&gt;&lt;span class="s"&gt;"image/avif"&lt;/span&gt;
    &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"
      /hero-400.avif   400w,
      /hero-800.avif   800w,
      /hero-1200.avif 1200w
    "&lt;/span&gt;
    &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 768px) 100vw, 1200px"&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;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;
    &lt;span class="na"&gt;srcset=&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;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 768px) 100vw, 1200px"&lt;/span&gt;
  &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  ![Hero image](https://renderlog.in/hero-1200.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 works through &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; elements in order, picks the first one it supports, and applies the same size selection algorithm from &lt;code&gt;srcset&lt;/code&gt;. A browser that supports AVIF gets AVIF at the right size. A browser that does not falls through to WebP. If neither is supported, the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; fallback loads the JPEG.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes that make this silently fail
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Using pixel density descriptors instead of width descriptors.&lt;/strong&gt; There is a second descriptor syntax that uses &lt;code&gt;x&lt;/code&gt; instead of &lt;code&gt;w&lt;/code&gt;, like &lt;code&gt;srcset="/hero-2x.jpg 2x"&lt;/code&gt;. This only handles DPR, not viewport size. It is the older syntax and far less flexible. Use &lt;code&gt;w&lt;/code&gt; descriptors with &lt;code&gt;sizes&lt;/code&gt; instead.&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;!-- Older approach: only handles DPR, ignores viewport size --&amp;gt;&lt;/span&gt;
![Hero](https://renderlog.in/hero.jpg)

&lt;span class="c"&gt;&amp;lt;!-- Better: handles both DPR and viewport size --&amp;gt;&lt;/span&gt;
![Hero](https://renderlog.in/hero-800.jpg)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Wrong sizes values.&lt;/strong&gt; If your &lt;code&gt;sizes&lt;/code&gt; attribute says &lt;code&gt;100vw&lt;/code&gt; but the image is actually displayed at 50% of the viewport inside a two-column layout, the browser will fetch a file twice as large as needed. Get the rendered width right, even if it means inspecting the layout in DevTools to measure the actual computed width at each breakpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skipping sizes entirely.&lt;/strong&gt; Without &lt;code&gt;sizes&lt;/code&gt;, the browser assumes the image is 100vw. On a 1440px desktop with a two-column layout where your image is actually 600px wide, the browser fetches a 1440px image. The default assumption is almost always too large for content images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not including the image dimensions.&lt;/strong&gt; Without &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element, the browser cannot reserve space for the image before it loads. When the image arrives, the page reflows and you get Cumulative Layout Shift. This applies whether you use &lt;code&gt;srcset&lt;/code&gt; or not, but it is especially visible with lazy-loaded responsive images because the load is intentionally deferred.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about CSS background images?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; only work on &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; elements and &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; elements inside &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;. CSS background images do not benefit from this system at all.&lt;/p&gt;

&lt;p&gt;For responsive CSS background images, the only native option is &lt;code&gt;image-set()&lt;/code&gt;:&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;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image-set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sx"&gt;url('/hero-800.webp')&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sx"&gt;url('/hero-1600.webp')&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="n"&gt;x&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;image-set()&lt;/code&gt; is less flexible than &lt;code&gt;srcset&lt;/code&gt; because it only handles DPR, not viewport size. For hero sections or any prominent image, using an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element with proper &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; is almost always the better choice over a CSS background image.&lt;/p&gt;




&lt;h2&gt;
  
  
  Generating responsive images in practice
&lt;/h2&gt;

&lt;p&gt;Manually creating and maintaining multiple resized versions of every image is not practical at scale. The standard approach is to generate them automatically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vite and Astro&lt;/strong&gt; both have image optimization plugins that generate multiple sizes at build time from a single source image. Astro's &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; component handles &lt;code&gt;srcset&lt;/code&gt; generation automatically when you specify a &lt;code&gt;widths&lt;/code&gt; prop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js&lt;/strong&gt; provides an &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; component that generates &lt;code&gt;srcset&lt;/code&gt; and handles format selection automatically, including on-demand resizing through its image optimization API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CDN image optimization services&lt;/strong&gt; like Cloudinary, Imgix, and Cloudflare Images let you request any size via URL parameters and serve the optimal format based on the &lt;code&gt;Accept&lt;/code&gt; header. You store one original image, the CDN handles the rest.&lt;/p&gt;

&lt;p&gt;The common thread is that the developer should not be manually maintaining five versions of every image. The build tool or CDN should generate them. What the developer needs to understand is &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; so they know what the tool is doing and can write the right configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  The performance case in numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Without srcset&lt;/th&gt;
&lt;th&gt;With srcset&lt;/th&gt;
&lt;th&gt;Savings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 13 (375px, 3x)&lt;/td&gt;
&lt;td&gt;2400px image, ~420KB&lt;/td&gt;
&lt;td&gt;1200px image, ~95KB&lt;/td&gt;
&lt;td&gt;77%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPad (768px, 2x)&lt;/td&gt;
&lt;td&gt;2400px image, ~420KB&lt;/td&gt;
&lt;td&gt;1600px image, ~165KB&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1080p desktop (1x)&lt;/td&gt;
&lt;td&gt;2400px image, ~420KB&lt;/td&gt;
&lt;td&gt;1200px image, ~95KB&lt;/td&gt;
&lt;td&gt;77%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4K desktop (2x)&lt;/td&gt;
&lt;td&gt;2400px image, ~420KB&lt;/td&gt;
&lt;td&gt;2400px image, ~420KB&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only device in this table that should download the full 2400px image is the 4K display with a 2x DPR where the image fills the viewport. Every other device is paying for data that gets discarded after the browser scales the image down. &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; stop that from happening.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/srcset-sizes-responsive-images-explained/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/srcset-sizes-responsive-images-explained/&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>browser</category>
      <category>frontend</category>
    </item>
    <item>
      <title>What loading=lazy Does: Browser Lazy Loading</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Fri, 08 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/what-loadinglazy-does-browser-lazy-loading-1678</link>
      <guid>https://dev.to/helloashish99/what-loadinglazy-does-browser-lazy-loading-1678</guid>
      <description>&lt;p&gt;Related: &lt;a href="https://renderlog.in/blog/images-fonts-third-party-performance/" rel="noopener noreferrer"&gt;Images, Fonts, and Third-Party Scripts: LCP and CLS Killers&lt;/a&gt; covers the full image performance picture including format selection and preloading strategies.&lt;/p&gt;

&lt;p&gt;If you have ever added &lt;code&gt;loading="lazy"&lt;/code&gt; to an image tag without knowing exactly what it does, you are not alone. Most developers copy it from tutorials and move on. But there is a specific reason Google added it to the HTML spec, there is a specific mechanism that makes it work, and there are two situations where using it will actively hurt your performance scores instead of helping them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; Where the browser's default image loading behavior falls short, what Google found in their 2019 profiling study, how the browser calculates when to start fetching a lazy image, and the two cases where &lt;code&gt;loading="lazy"&lt;/code&gt; should never appear.&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%2F48xzo6lm62xlx8gl3gdk.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%2F48xzo6lm62xlx8gl3gdk.png" alt="Diagram comparing eager image loading versus native lazy loading, showing how the browser defers offscreen image fetches until the user scrolls near the viewport." width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this attribute exists at all
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;loading="lazy"&lt;/code&gt; was part of the HTML spec, developers who wanted to defer offscreen image loads had to write JavaScript. The standard approach was to use &lt;code&gt;IntersectionObserver&lt;/code&gt;, set the real &lt;code&gt;src&lt;/code&gt; on a data attribute, and swap it in when the image entered the viewport.&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;// The JavaScript-based lazy loading pattern from before 2019&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;images&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;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;img[data-src]&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;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="nx"&gt;entries&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;entries&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;entry&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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&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;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&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;unobserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&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="nx"&gt;images&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;img&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;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern worked, but it had a problem: the JavaScript had to be downloaded and executed before any images could be lazily loaded. If the script was render-blocking or deferred, you had a window where images loaded eagerly anyway. If the user had JavaScript disabled, every image loaded immediately regardless.&lt;/p&gt;

&lt;p&gt;Google's Chrome team looked at how images were actually being loaded across the web. Their profiling found that &lt;strong&gt;images below the fold were responsible for 30 to 50 percent of initial bandwidth&lt;/strong&gt; on median web pages. These are images the user may never see, especially on long pages where the majority of visitors never scroll past the first two screens.&lt;/p&gt;

&lt;p&gt;They proposed a native solution: a browser-level attribute that requires no JavaScript, loads before any scripts run, and degrades gracefully when JavaScript is unavailable. The W3C standardized it, and Chrome shipped it in version 77 in September 2019. Firefox followed in version 75. Safari added it in 15.4.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the browser decides when to fetch a lazy image
&lt;/h2&gt;

&lt;p&gt;When you add &lt;code&gt;loading="lazy"&lt;/code&gt; to an image, the browser does not simply wait until the image is in the viewport to start fetching it. It uses a &lt;strong&gt;distance-from-viewport threshold&lt;/strong&gt; that varies based on the connection speed.&lt;/p&gt;

&lt;p&gt;On a fast connection, Chrome starts fetching a lazy image when it is within &lt;strong&gt;1250px&lt;/strong&gt; of the viewport. On a slow connection (2G, slow-3G), that threshold drops to &lt;strong&gt;2500px&lt;/strong&gt;. The idea is that on a slow connection, the browser needs more lead time to fetch the image before the user scrolls to it.&lt;/p&gt;

&lt;p&gt;This is why lazy loading does not cause a visible flash or delay on most scroll interactions. By the time most users scroll down, the browser has already started fetching the image that is about to appear.&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;!-- This image starts loading when it is ~1250px below the viewport --&amp;gt;&lt;/span&gt;
![Article thumbnail](https://renderlog.in/blog/thumbnail.webp)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold is implemented inside the browser's network stack, not in JavaScript. That means it runs before any of your application code, and it runs even when JavaScript is completely disabled.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the browser does with the saved bandwidth
&lt;/h2&gt;

&lt;p&gt;The bandwidth saved from not loading below-fold images is not wasted. The browser reallocates it to higher-priority resources: your CSS, your fonts, your LCP image, and your JavaScript. On pages with many images, the cascade effect is significant.&lt;/p&gt;

&lt;p&gt;A page with 12 images where only 3 are above the fold saves 9 image fetches from the initial network queue. Those freed connections let critical resources load faster. First Contentful Paint improves. LCP improves. Time to Interactive improves.&lt;/p&gt;

&lt;p&gt;The exact gains depend on the page and the connection, but Google's own measurements showed that adding &lt;code&gt;loading="lazy"&lt;/code&gt; to below-fold images on the Chrome developer documentation site reduced the total bytes downloaded on the initial load by around 40 percent.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two situations where you should never use it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The LCP image.&lt;/strong&gt; The Largest Contentful Paint element is usually the hero image or the first content image on the page. If you add &lt;code&gt;loading="lazy"&lt;/code&gt; to it, the browser will defer fetching it until it calculates that the image is close to the viewport, which happens after layout is complete, which is after CSS is parsed, which can be several hundred milliseconds into the page load.&lt;/p&gt;

&lt;p&gt;On a fast connection with a small HTML document, this barely matters. On a slow connection or a page with a large HTML document, this difference can push LCP from 2 seconds to 5 seconds.&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;!-- Never do this for your LCP image --&amp;gt;&lt;/span&gt;
![Hero](https://renderlog.in/hero.webp)

&lt;span class="c"&gt;&amp;lt;!-- Do this instead --&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;Images in the first screenful without a known position.&lt;/strong&gt; If an image is placed early in the HTML but its final position depends on CSS that has not loaded yet, the browser cannot calculate whether it is in the viewport. It may incorrectly defer the fetch. If the image turns out to be above the fold once layout completes, you will see a visible pop-in.&lt;/p&gt;

&lt;p&gt;The fix in both cases is &lt;code&gt;loading="eager"&lt;/code&gt;, which is the browser's default for images anyway. You are not doing anything special with it, you are just being explicit that this image should not be deferred.&lt;/p&gt;




&lt;h2&gt;
  
  
  The width and height attributes are required
&lt;/h2&gt;

&lt;p&gt;When you defer an image load, the browser does not know the image dimensions until it fetches the image. If it does not know the dimensions, it renders the image as 0x0 and then expands it when the image loads. That expansion shifts the content below it, causing Cumulative Layout Shift.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: always include &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on every image, lazy or not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;![Diagram showing the rendering pipeline](https://renderlog.in/article/diagram.png)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With explicit dimensions, the browser reserves the exact space for the image before it loads. When the image arrives, nothing shifts. This applies to all images, but it matters most for lazy-loaded ones because the fetch is deferred and the gap between "image slot appears" and "image arrives" is longer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Browser support and the polyfill situation
&lt;/h2&gt;

&lt;p&gt;Native &lt;code&gt;loading="lazy"&lt;/code&gt; is supported in Chrome 77+, Firefox 75+, Edge 79+, and Safari 15.4+. That covers somewhere around 95 percent of global browser usage as of 2026.&lt;/p&gt;

&lt;p&gt;For the remaining 5 percent (primarily older Safari versions and some legacy Android browsers), the browser simply ignores the attribute and loads images normally. This is the correct degraded behavior. The page still works, images still load, you just lose the performance benefit.&lt;/p&gt;

&lt;p&gt;No polyfill is required for production use. The progressively enhanced behavior is exactly what you want.&lt;/p&gt;

&lt;p&gt;If you specifically need to support very old browsers and want lazy loading there too, the &lt;code&gt;lazysizes&lt;/code&gt; library implements the IntersectionObserver approach as a fallback. But for most applications, the native attribute is sufficient and simpler.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image position&lt;/th&gt;
&lt;th&gt;loading attribute&lt;/th&gt;
&lt;th&gt;fetchpriority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hero / LCP image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Above the fold, not LCP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(omit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Below the fold&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lazy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(omit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep in page (below 2 screens)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lazy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(omit)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule is simple: anything the user sees without scrolling gets &lt;code&gt;loading="eager"&lt;/code&gt;. Everything else gets &lt;code&gt;loading="lazy"&lt;/code&gt;. The default browser behavior without the attribute is to load everything eagerly, which is why the attribute exists in the first place.&lt;/p&gt;

&lt;p&gt;One attribute, no JavaScript, no library dependency, immediate performance improvement. The reason Google pushed it into the spec is that this pattern was so universally correct that it should not require developer effort to implement on every site individually.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/loading-lazy-html-attribute-how-it-works/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/loading-lazy-html-attribute-how-it-works/&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>browser</category>
      <category>lcp</category>
    </item>
    <item>
      <title>Lighthouse 100 and Still Crashes: OOM Explained</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Thu, 07 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/lighthouse-100-and-still-crashes-oom-explained-5424</link>
      <guid>https://dev.to/helloashish99/lighthouse-100-and-still-crashes-oom-explained-5424</guid>
      <description>&lt;p&gt;A Lighthouse score of 100 means the page loads efficiently in a clean, synthetic test environment; it says nothing about what happens after 6 hours of continuous use — a hundred route navigations, thousands of polling cycles, and a React Query cache holding responses that should have been evicted hours ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why lab metrics miss this:&lt;/strong&gt; Lighthouse runs a single page load on a clean browser instance. It cannot measure heap growth rate, memory fragmentation, or tab survival probability on a 3GB Android device. OOM tab crashes don't produce JavaScript errors — the renderer process just dies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; What OOM crashes actually are and why Chrome's tab limits differ by device, why long-lived SPAs accumulate memory (route state, unbounded caches, subscription leaks), how to monitor heap growth in production, and strategies that keep apps stable over 8-hour sessions.&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%2Fw6qnwzz6tujjjo4zd918.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%2Fw6qnwzz6tujjjo4zd918.png" alt="Diagram contrasting Lighthouse lab scores with long-session memory growth and OOM risk that lab tools do not capture." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Lab vs Field Gap
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lighthouse&lt;/strong&gt; runs a single, synthetic page load on a clean browser instance with simulated throttling. It measures what happens during that load  LCP, CLS, TBT, FID, and overall performance  and produces a score. For what it measures, it's accurate and useful.&lt;/p&gt;

&lt;p&gt;What it doesn't measure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Not measured by Lighthouse&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory growth over time&lt;/td&gt;
&lt;td&gt;Long-lived apps accumulate heap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory after 100 navigations&lt;/td&gt;
&lt;td&gt;SPAs never fully unload between routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GC pressure under sustained load&lt;/td&gt;
&lt;td&gt;Polling loops cause GC pauses over hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory fragmentation&lt;/td&gt;
&lt;td&gt;Old generation becomes fragmented; GC is less effective&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tab survival on low-RAM devices&lt;/td&gt;
&lt;td&gt;Chrome kills tabs proactively on 2GB-RAM phones&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;Core Web Vitals field data&lt;/strong&gt; in CrUX (Chrome User Experience Report) does capture real users, but it captures the &lt;em&gt;load&lt;/em&gt; experience of those users, not their session survival rate. There's no CrUX metric for "tab was killed by the OS after 4 hours." That data simply doesn't exist in any aggregate form you can easily access.&lt;/p&gt;

&lt;p&gt;This creates a dangerous blind spot. You can achieve a perfect Lighthouse score and still be shipping software that degrades and crashes for users who use it the way internal tools get used  all day, every day, with the same tab.)&lt;/p&gt;




&lt;h2&gt;
  
  
  What an OOM Tab Crash Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;OOM&lt;/strong&gt; stands for &lt;strong&gt;out of memory&lt;/strong&gt;. In Chrome, each tab runs in its own renderer process with a memory limit. When the heap grows beyond that limit, Chrome's renderer terminates the process and shows the user an "Aw, Snap!" error page.&lt;/p&gt;

&lt;p&gt;The limit isn't a fixed number. Chrome sets it dynamically based on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The total available system RAM&lt;/li&gt;
&lt;li&gt;How many other tabs are open&lt;/li&gt;
&lt;li&gt;Chrome's own heuristics for memory pressure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a desktop machine with 16GB RAM and only a few tabs open, a single tab can use 2-4GB before Chrome kills it. On a mobile device with 3GB RAM running multiple apps, Chrome may proactively kill background tabs (not even the active one) when the system is under memory pressure.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;&lt;code&gt;performance.memory&lt;/code&gt;&lt;/strong&gt; API (Chrome only, non-standard) exposes some of these limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&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="c1"&gt;// Total heap size V8 has allocated (committed memory)&lt;/span&gt;
    &lt;span class="na"&gt;totalJSHeapSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalJSHeapSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// How much of that heap is actually in use by JS objects&lt;/span&gt;
    &lt;span class="na"&gt;usedJSHeapSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usedJSHeapSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// The hard limit  heap cannot grow beyond this&lt;/span&gt;
    &lt;span class="na"&gt;jsHeapSizeLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jsHeapSizeLimit&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;On a typical desktop Chrome, &lt;code&gt;jsHeapSizeLimit&lt;/code&gt; is around 4GB. On Android Chrome on a 3GB device, it's often 512MB-1GB. When &lt;code&gt;usedJSHeapSize&lt;/code&gt; approaches &lt;code&gt;jsHeapSizeLimit&lt;/code&gt;, the tab is on the edge of an OOM crash.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mobile RAM: The Real Constraint
&lt;/h2&gt;

&lt;p&gt;The operations team using the dashboard I mentioned were on desktop machines. But the majority of web traffic globally is on mobile, and mobile RAM constraints are severe.&lt;/p&gt;

&lt;p&gt;Chrome on Android uses the following rough thresholds for tab killing (these aren't official numbers  they're inferred from behavior and reported by developers and researchers):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device RAM&lt;/th&gt;
&lt;th&gt;Approx. tab heap limit&lt;/th&gt;
&lt;th&gt;Background tab kill threshold&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 GB&lt;/td&gt;
&lt;td&gt;~150-200MB&lt;/td&gt;
&lt;td&gt;~50MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 GB&lt;/td&gt;
&lt;td&gt;~250-350MB&lt;/td&gt;
&lt;td&gt;~100MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 GB&lt;/td&gt;
&lt;td&gt;~400-500MB&lt;/td&gt;
&lt;td&gt;~200MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 GB+&lt;/td&gt;
&lt;td&gt;~700MB+&lt;/td&gt;
&lt;td&gt;~300MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a 2GB Android device  still common in many markets  a tab that uses 300MB of heap will be proactively killed by Chrome when the user switches to another app. When they switch back, Chrome reloads the tab from scratch, losing all state. Users experience this as "the page keeps refreshing."&lt;/p&gt;

&lt;p&gt;For apps that need to survive on mobile, 150-200MB is a realistic heap budget to aim for in steady state. This is much tighter than desktop, and it rules out certain architectural decisions  like caching every API response in memory indefinitely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Long Sessions Break Apps That Pass Lighthouse
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Accumulated Route State
&lt;/h3&gt;

&lt;p&gt;React Router and Next.js don't fully unmount pages on navigation in SPAs. The framework manages route transitions, but unless you're explicitly code-splitting and unloading modules, the JavaScript for visited routes stays loaded. More importantly, React components that were mounted may hold state in closures, context, or stores.&lt;/p&gt;

&lt;p&gt;Consider a dashboard with 20 different report views. After visiting all 20, the user has accumulated the component trees, data structures, and side effects of all 20 pages. If any of those pages has a memory leak (even a small one), it compounds with each visit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache Without Eviction
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;React Query&lt;/strong&gt; and &lt;strong&gt;SWR&lt;/strong&gt; are excellent data fetching libraries. Their defaults are also memory-addictive for long-running apps. React Query, by default, keeps cached data in memory for 5 minutes after it's no longer actively used. In a dashboard that queries 200 unique order IDs over a day of use, that's 200 cached responses held in memory simultaneously.&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;// React Query default: caches everything for 5 minutes&lt;/span&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Tuned for long-lived apps&lt;/span&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;QueryClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;defaultOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&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;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// data is fresh for 30s&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;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// remove from cache after 1 minute of disuse (was cacheTime in v4)&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;Reducing &lt;code&gt;gcTime&lt;/code&gt; from 5 minutes to 1 minute sounds minor. Over 8 hours of active use, it's the difference between holding thousands of cached responses and holding only the last few minutes of data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Listener and Subscription Accumulation
&lt;/h3&gt;

&lt;p&gt;Even small leaks compound over time. An event listener that's not removed on component unmount is tiny  maybe a few KB of retained closures. After 500 route navigations (common in a full workday), those small leaks add up to hundreds ofMB.&lt;/p&gt;

&lt;p&gt;I covered this in detail in the &lt;a href="https://renderlog.in/blog/react-memory-retained-graphs-leaks/" rel="noopener noreferrer"&gt;React Memory Leaks post&lt;/a&gt;, but the time dimension is what makes it a production crash issue vs a development curiosity. In a unit test or Lighthouse run, the component mounts once and unmounts once. In 8 hours of use, it might mount and unmount 300 times.&lt;/p&gt;




&lt;h2&gt;
  
  
  Diagnosing Production OOM
&lt;/h2&gt;

&lt;p&gt;OOM crashes are hard to diagnose because they don't produce a JavaScript error. The renderer process just dies. There's no stack trace, no error boundary trigger, no Sentry event.&lt;/p&gt;

&lt;p&gt;Field signals to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session duration distribution&lt;/strong&gt;: if your analytics shows a spike in session ends at 3-4 hours, users are being kicked out (either by OOM or by giving up on a slow app)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigation abandonment&lt;/strong&gt;: if users stop navigating after a certain number of route changes, the app may be degrading&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;performance.memory&lt;/code&gt; logging&lt;/strong&gt;: log &lt;code&gt;usedJSHeapSize&lt;/code&gt; periodically (every 5 minutes) to your analytics service. Over time this builds a picture of heap growth rate per user type
&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;// Log memory usage every 5 minutes to your analytics service&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startMemoryMonitoring&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;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&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;// Chrome only&lt;/span&gt;

  &lt;span class="nf"&gt;setInterval&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;usedJSHeapSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jsHeapSizeLimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&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;usagePercent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usedJSHeapSize&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;jsHeapSizeLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;memory_usage&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;usedMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usedJSHeapSize&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;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;limitMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jsHeapSizeLimit&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;1024&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;usagePercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usagePercent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Warn if approaching limit&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;usagePercent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;80&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="s2"&gt;`Heap at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;usagePercent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;% of limit`&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;PerformanceObserver&lt;/strong&gt; &lt;code&gt;type: 'memory'&lt;/code&gt; API (Chrome M89+) provides a more event-driven approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;supportedEntryTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;memory&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;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;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;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;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Memory entry:&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&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="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;memory&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;As of 2026 this API is still Chrome-only and not widely used, but it's the right long-term direction for memory monitoring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Strategies for Long-Lived Apps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Route-Based Code Splitting and Proper Unmounting
&lt;/h3&gt;

&lt;p&gt;React Router v6's &lt;code&gt;&amp;lt;Outlet&amp;gt;&lt;/code&gt; and lazy loading ensure that route components are code-split. But code-splitting only controls the &lt;em&gt;initial load&lt;/em&gt;  once a module is loaded, it stays in memory. The more important mechanism is ensuring components fully unmount and release their state.&lt;/p&gt;

&lt;p&gt;In practice this means auditing that route components don't hold global references, that their &lt;code&gt;useEffect&lt;/code&gt; cleanups fire correctly, and that stores don't accumulate data from visited routes indefinitely.&lt;/p&gt;

&lt;h3&gt;
  
  
  LRU Cache Eviction
&lt;/h3&gt;

&lt;p&gt;For any client-side cache, implement maximum size with LRU (least recently used) eviction:&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;detailCache&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;LRUCache&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                     &lt;span class="c1"&gt;// keep at most 50 items&lt;/span&gt;
  &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&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;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// expire items after 5 minutes&lt;/span&gt;
  &lt;span class="na"&gt;maxSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&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;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// cap at 50MB total&lt;/span&gt;
  &lt;span class="na"&gt;sizeCalculation&lt;/span&gt;&lt;span class="p"&gt;:&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="o"&gt;=&amp;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="nx"&gt;length&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;lru-cache&lt;/code&gt; is the standard choice in the Node/browser ecosystem  well-maintained, tiny, fast. For React Query, use the &lt;code&gt;gcTime&lt;/code&gt; option as shown earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Page Visibility API: Reduce Background Work
&lt;/h3&gt;

&lt;p&gt;When the user switches tabs or minimizes the browser, there's no reason to keep polling APIs at full speed. The &lt;strong&gt;Page Visibility API&lt;/strong&gt; lets you detect visibility changes:&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;document&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;visibilitychange&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="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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tab is hidden  reduce or stop background work&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;pauseMutations&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;stopPolling&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Tab is visible again  resume&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;resumeMutations&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;startPolling&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;invalidateQueries&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// fetch fresh data&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;Stopping API polling when the tab is hidden also prevents stale data from accumulating in caches that aren't being actively evicted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idle Reload for Kiosks and Operational Dashboards
&lt;/h3&gt;

&lt;p&gt;For dashboards that need to be 100% reliable over long periods, the nuclear option is a scheduled reload during periods of inactivity. This sounds crude but is genuinely effective for kiosk displays, NOC dashboards, and ops tooling:&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;scheduleIdleReload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idleMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&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;idleTimer&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;resetTimer&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;idleTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;idleTimer&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="c1"&gt;// User has been inactive for `idleMinutes`  reload the page&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;idleMinutes&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Reset on any user interaction&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mousedown&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="s2"&gt;keydown&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="s2"&gt;touchstart&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="s2"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;"&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;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="nb"&gt;document&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resetTimer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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="nf"&gt;resetTimer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Reload if idle for 2 hours&lt;/span&gt;
&lt;span class="nf"&gt;scheduleIdleReload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a 2-hour idle reload, a tab that's been left open all night reloads when the ops team comes in the morning. Fresh state, no accumulated heap. Combined with proper session restoration (saving the current view to &lt;code&gt;sessionStorage&lt;/code&gt;), this is invisible to users.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Memory Budget for Long-Running SPAs
&lt;/h2&gt;

&lt;p&gt;Based on my experience with internal tools and operations dashboards, here's a rough budget that keeps long-running apps stable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Desktop Target&lt;/th&gt;
&lt;th&gt;Mobile Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Initial page load heap&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 50MB&lt;/td&gt;
&lt;td&gt;&amp;lt; 30MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Steady-state heap&lt;/strong&gt; (after 1 hour)&lt;/td&gt;
&lt;td&gt;&amp;lt; 150MB&lt;/td&gt;
&lt;td&gt;&amp;lt; 80MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Maximum acceptable heap&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 400MB&lt;/td&gt;
&lt;td&gt;&amp;lt; 150MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API response cache size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 50MB&lt;/td&gt;
&lt;td&gt;&amp;lt; 20MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your app exceeds the "maximum acceptable" column, it's at elevated risk of OOM on the target device class. Use &lt;code&gt;performance.memory&lt;/code&gt; logging in production to measure where your real users actually land.&lt;/p&gt;

&lt;p&gt;Lighthouse will never tell you about these numbers. It can't  it doesn't run for an hour. But they're what determines whether your users can actually use your app through a full working day.&lt;/p&gt;

&lt;p&gt;Reducing React Query &lt;code&gt;gcTime&lt;/code&gt; from 5 minutes to 90 seconds, pausing polling on tab hide, and deploying an idle reload (4-hour threshold) are the three changes that keep most long-lived dashboards stable. Heap stays under 200MB for the full working day. Lighthouse score stays 100. It just isn't the metric that matters for this class of problem.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/lighthouse-100-still-crashes-memory/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/lighthouse-100-still-crashes-memory/&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>memory</category>
      <category>lighthouse</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to Answer Frontend Performance Questions in Interviews</title>
      <dc:creator>Ashish Kumar</dc:creator>
      <pubDate>Wed, 06 May 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/helloashish99/how-to-answer-frontend-performance-questions-in-interviews-4hc3</link>
      <guid>https://dev.to/helloashish99/how-to-answer-frontend-performance-questions-in-interviews-4hc3</guid>
      <description>&lt;p&gt;"How do you optimize a slow React app?" is deceptively open-ended. Answer without structure and you'll spend five minutes on memoization, forget to mention network or memory, and leave the interviewer unsure whether you have a repeatable process or just a collection of tricks.&lt;/p&gt;

&lt;p&gt;The framework that works is &lt;strong&gt;seven distinct problem areas&lt;/strong&gt;, each with different root causes, different debugging tools, and different solutions. The right answer to a performance question starts with identifying which area is the actual bottleneck, because optimizing re-renders when the problem is network is wasted effort, and chasing bundle size when the tab is leaking memory doesn't move the needle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this covers:&lt;/strong&gt; A complete framework for answering frontend performance questions in interviews, organized as seven areas that mirror how performance problems actually present in production code.&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%2Fihd0l2f9irb3qrp8h3kj.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%2Fihd0l2f9irb3qrp8h3kj.png" alt="Compass-style diagram for frontend performance interviews: rendering, network, memory, build, and related problem buckets." width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The framing I always start with
&lt;/h2&gt;

&lt;p&gt;Before I get into any specific technique, I say something like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I approach performance as seven distinct problem areas. Each has different root causes, different debugging tools, and different solutions. When I join a slow app, the first thing I do is identify which area is actually the bottleneck, because optimizing rendering when the problem is network is a waste of time."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence alone signals structured thinking. Interviewers are pattern-matching for "does this person have a repeatable process, or do they just know individual tricks?"&lt;/p&gt;

&lt;p&gt;Then I walk through the seven areas. Here's exactly how I explain each one.&lt;/p&gt;




&lt;h2&gt;
  
  
  How measurement ties the seven buckets together
&lt;/h2&gt;

&lt;p&gt;Interviewers often like hearing that you &lt;strong&gt;classify before you optimize&lt;/strong&gt;. I map symptoms to buckets so I do not “fix” re-renders when the real problem is LCP, or chase bundle size when the tab is leaking memory.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If the symptom looks like…&lt;/th&gt;
&lt;th&gt;I look here first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slow first paint, blank screen, huge JS&lt;/td&gt;
&lt;td&gt;Build (#5), assets (#6), network (#3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Janky scroll, input lag, stuttering animations&lt;/td&gt;
&lt;td&gt;Main thread, frame budget, long tasks, re-renders (#2), sometimes huge DOM (#1, #4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine in Lighthouse, bad on a cheap phone&lt;/td&gt;
&lt;td&gt;Mobile DOM and paint cost (#4), images/fonts (#6)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine for five minutes, awful after an hour&lt;/td&gt;
&lt;td&gt;Long-session memory (#7)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is not a script—it is a &lt;strong&gt;debugging compass&lt;/strong&gt;. In a real job I confirm with DevTools (Performance, Network, Memory) or RUM. On the blog I wrote up the underlying models here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/browser-main-thread-rendering-pipeline/" rel="noopener noreferrer"&gt;The browser’s main thread and rendering pipeline&lt;/a&gt;: what actually runs per frame&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/16ms-frame-budget-60fps/" rel="noopener noreferrer"&gt;The ~16.6 ms frame budget at 60 Hz&lt;/a&gt;: why “fast load” and “smooth runtime” are different problems&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/core-web-vitals-lighthouse-explained/" rel="noopener noreferrer"&gt;Core Web Vitals and Lighthouse&lt;/a&gt;: what LCP, CLS, and INP reward and what they miss&lt;/li&gt;
&lt;li&gt;
&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;: why INP and “jank” often trace to the same root cause&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/frontend-performance-how-why-hub/" rel="noopener noreferrer"&gt;Performance hub: quick “how / why” reference&lt;/a&gt;: layout thrashing, &lt;code&gt;will-change&lt;/code&gt;, passive listeners, and other one-liners you can name-check in an interview&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Rendering large datasets
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; You have a list, table, or feed with thousands of rows (an order book, a data grid, an activity log). You render them all and the browser slows to a crawl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I explain the trade-offs:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first thing I do is separate the three approaches, because teams often conflate them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it does&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;Pagination&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Renders only one page of data at a time; user explicitly navigates&lt;/td&gt;
&lt;td&gt;Read-heavy tables, URL-addressable records, SEO content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infinite scroll&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Appends more items as user scrolls; old items stay in DOM&lt;/td&gt;
&lt;td&gt;Social feeds, content discovery, low-importance archives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Virtualization&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only renders viewport rows; DOM count stays constant regardless of data size&lt;/td&gt;
&lt;td&gt;Real-time dashboards, large data grids, performance-critical lists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I always point out what most candidates miss: &lt;strong&gt;infinite scroll has a hidden DOM growth problem&lt;/strong&gt;. Every appended batch stays in the DOM. After 50 batches, you have the same performance problem as rendering everything up front — just delayed by a few minutes of scrolling. I've seen "infinite scroll" ship as a performance fix that actually made things worse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtualization is the real answer for truly large datasets.&lt;/strong&gt; Libraries like &lt;code&gt;react-window&lt;/code&gt; and TanStack Virtual render only the rows inside (or near) the viewport, keeping the DOM node count constant at ~20-50 nodes regardless of whether the dataset has 500 or 500,000 items.&lt;/p&gt;

&lt;p&gt;The trade-offs worth mentioning in an interview: variable-height rows are harder to virtualize (you need a measurement strategy), screen readers can behave unexpectedly with windowed lists (you need &lt;code&gt;aria-rowcount&lt;/code&gt; and &lt;code&gt;aria-rowindex&lt;/code&gt;), and SSR hydration needs thought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the dataset is huge on the client or work is CPU-heavy&lt;/strong&gt;, I briefly mention moving work &lt;strong&gt;off the main thread&lt;/strong&gt; and using &lt;strong&gt;fast local I/O&lt;/strong&gt; so the UI thread stays responsive—then I only go deep if they bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/web-workers-frontend-react/" rel="noopener noreferrer"&gt;Web Workers in frontend and React&lt;/a&gt;: parsing, transforms, isolation from the DOM&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/origin-private-file-system-opfs/" rel="noopener noreferrer"&gt;Origin Private File System (OPFS)&lt;/a&gt;: large blobs, streaming, pairing with workers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That keeps the answer honest for dashboards and editors without derailing a 45-minute loop into File System Access API details.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep dive:&lt;/strong&gt; &lt;a href="https://renderlog.in/blog/large-lists-virtualization-trade-offs/" rel="noopener noreferrer"&gt;Large Lists: Pagination, Infinite Scroll, and Virtualization&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Re-rendering issues
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; The app is slow during interaction — not on load. Something is causing too many components to re-render too often.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I explain the debugging process:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I always start with the profiler, not the code. React DevTools Profiler shows you exactly which components re-rendered, how long each took, and, critically, &lt;strong&gt;why&lt;/strong&gt; each re-rendered (prop change, state change, context change, parent re-render). Until you've looked at the profiler output, you're guessing.&lt;/p&gt;

&lt;p&gt;The re-rendering mental model I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A component re-renders when:
1. Its own state changes
2. Its props change (by reference, not value — objects and functions are recreated every render)
3. A context it subscribes to changes
4. Its parent re-renders (unless wrapped in React.memo with stable props)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most common production issue I find is &lt;strong&gt;context broadcasting&lt;/strong&gt; — a context that changes frequently (live data, user interaction state) is consumed by a large subtree, causing everything to re-render on every update. The fix is splitting contexts by update frequency: separate the fast-changing data context from the slow-changing config context.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;React.memo&lt;/code&gt;, &lt;code&gt;useMemo&lt;/code&gt;, and &lt;code&gt;useCallback&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;These are referential stability tools, not magic performance switches. &lt;code&gt;React.memo&lt;/code&gt; wraps a component in a shallow prop comparison — if all props are the same reference as last render, the re-render is skipped. &lt;code&gt;useCallback&lt;/code&gt; gives you a stable function reference across renders. &lt;code&gt;useMemo&lt;/code&gt; memoizes a computed value.&lt;/p&gt;

&lt;p&gt;The trap I see constantly: developers add &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&lt;/code&gt; everywhere preemptively, adding overhead without benefit. You pay the cost of memoization (comparison + memory) on every render regardless of whether the memoization ever saves a re-render. &lt;strong&gt;Profile first. Memoize second.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the conversation turns to &lt;strong&gt;keeping inputs responsive while a subtree is expensive&lt;/strong&gt;, I add one layer: React 18’s concurrent features defer non-urgent updates so the main thread can still process typing and clicks. That is a different tool from memoization: scheduling**, not skipping work entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep dives:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://renderlog.in/blog/react-rerendering-when-trees-update/" rel="noopener noreferrer"&gt;React Re-rendering: When and Why Component Trees Update&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://renderlog.in/blog/react-memo-usememo-usecallback/" rel="noopener noreferrer"&gt;React.memo, useMemo, useCallback — When They Help vs Hurt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://renderlog.in/blog/react-concurrent-features-performance/" rel="noopener noreferrer"&gt;Concurrent React for perceived performance&lt;/a&gt;: &lt;code&gt;startTransition&lt;/code&gt;, &lt;code&gt;useDeferredValue&lt;/code&gt;, urgent vs deferred updates&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. Network optimization
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; The app makes too many requests, or makes them at the wrong time, or fetches data that's already available from a previous request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I explain the SPA network problem:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Single-page apps have a structural disadvantage vs server-rendered pages: data fetching happens in the browser, sequentially. You navigate to a route → component mounts → &lt;code&gt;useEffect&lt;/code&gt; fires → fetch starts → data arrives → render. That's a full round-trip after every navigation. Stack nested routes that each fetch their own data and you get a &lt;strong&gt;waterfall&lt;/strong&gt; — each fetch waits for the parent component's render before it even starts.&lt;/p&gt;

&lt;p&gt;The pattern I always mention: &lt;strong&gt;move fetches out of components and into route loaders&lt;/strong&gt; (React Router 6.4+ loaders, Next.js &lt;code&gt;getServerSideProps&lt;/code&gt;/RSC). A route loader can start the fetch immediately when the user navigates — before the component tree even renders — and pass the data down as a resolved value. No useEffect, no loading spinner, no waterfall.&lt;/p&gt;

&lt;p&gt;For data that doesn't change on every page load, &lt;strong&gt;stale-while-revalidate&lt;/strong&gt; is the principle I use most. TanStack Query implements it well: return cached data immediately, revalidate in the background, update if something changed. Users see instant data; freshness is maintained. The key config knob is &lt;code&gt;staleTime&lt;/code&gt;: how long before a cache entry is considered stale.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caching strategy&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Freshness&lt;/th&gt;
&lt;th&gt;Right for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No cache (fetch on mount)&lt;/td&gt;
&lt;td&gt;Network RTT on every load&lt;/td&gt;
&lt;td&gt;Always fresh&lt;/td&gt;
&lt;td&gt;Auth state, financial data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale-while-revalidate&lt;/td&gt;
&lt;td&gt;Instant (cached) + background update&lt;/td&gt;
&lt;td&gt;Slightly stale&lt;/td&gt;
&lt;td&gt;Most UI data — profiles, feeds, configs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache-first with TTL&lt;/td&gt;
&lt;td&gt;Instant until TTL expires&lt;/td&gt;
&lt;td&gt;Stale until TTL&lt;/td&gt;
&lt;td&gt;Mostly-static data — categories, settings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immutable (by URL hash)&lt;/td&gt;
&lt;td&gt;Instant indefinitely&lt;/td&gt;
&lt;td&gt;Never updates&lt;/td&gt;
&lt;td&gt;Build artifacts, versioned API responses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Other points I always bring up: &lt;strong&gt;request deduplication&lt;/strong&gt; (two components requesting the same URL in the same render cycle should hit the network once, not twice; TanStack Query does this automatically), &lt;strong&gt;prefetching on hover&lt;/strong&gt; before the user clicks, and &lt;code&gt;fetchpriority="high"&lt;/code&gt; on LCP-critical resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep dive:&lt;/strong&gt; &lt;a href="https://renderlog.in/blog/network-optimization-spa-react/" rel="noopener noreferrer"&gt;Network Optimization for React SPAs&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Mobile view optimization
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; The app works on a MacBook and a high-end phone. It's unusable on a mid-range Android. You've never actually tested it on one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I explain the mobile performance gap:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Desktop performance profiling is misleading. A MacBook Pro has 8–16 fast cores. A Redmi Note 9, which represents a huge portion of actual smartphone users globally, has 4 slower cores, 4GB RAM, and a V8 JavaScript engine running at maybe 20% the throughput of a desktop Chrome instance. The 6x CPU throttle preset in Chrome DevTools is a useful approximation but still optimistic for low-end devices.&lt;/p&gt;

&lt;p&gt;The areas I focus on for mobile:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DOM size.&lt;/strong&gt; Every DOM node has a cost: memory, style matching, hit testing. A style recalculation on a page with 500 nodes is fast. On a page with 5,000 nodes it's slow. On mobile it's noticeably slow. I audit DOM size with &lt;code&gt;document.querySelectorAll('*').length&lt;/code&gt; and target under 1,500 nodes for complex pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;content-visibility: auto&lt;/code&gt;&lt;/strong&gt; is the highest-impact single CSS property I reach for on content-heavy mobile pages. It tells the browser to skip layout and paint for off-screen sections entirely; they're calculated only when they scroll into view. Combined with &lt;code&gt;contain-intrinsic-size&lt;/code&gt; to give the browser a height estimate, it can cut initial render time dramatically on long pages.&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;400px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* approximate height, prevents layout shift */&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;Passive event listeners.&lt;/strong&gt; Touch and wheel event listeners delay scroll by default because the browser waits to see if &lt;code&gt;preventDefault()&lt;/code&gt; is called. Adding &lt;code&gt;{ passive: true }&lt;/code&gt; tells the browser it can start scrolling immediately. This is one of the cheapest scroll performance wins available:&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;document&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;touchstart&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&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;&lt;strong&gt;Images.&lt;/strong&gt; &lt;code&gt;loading="lazy"&lt;/code&gt; for below-fold images, &lt;code&gt;decoding="async"&lt;/code&gt;, correct &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes to prevent layout shift, and properly sized &lt;code&gt;srcset&lt;/code&gt; so mobile devices don't download desktop-resolution images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep dive:&lt;/strong&gt; &lt;a href="https://renderlog.in/blog/mobile-web-dom-performance/" rel="noopener noreferrer"&gt;Mobile Web and DOM Performance&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Build optimizations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; The initial JavaScript payload is too large. Time to Interactive is slow because the browser is parsing and executing megabytes of JS before the app is usable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I explain the bundle problem:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most apps I've worked on that had large bundles had the same root causes: no code splitting, importing entire libraries when they only needed one function, and barrel files that prevented tree shaking from working.&lt;/p&gt;

&lt;p&gt;I always mention three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundle analysis first.&lt;/strong&gt; Before optimizing anything, visualize what's actually in the bundle. &lt;code&gt;rollup-plugin-visualizer&lt;/code&gt; (for Vite) and &lt;code&gt;webpack-bundle-analyzer&lt;/code&gt; produce a treemap showing exactly how much each dependency contributes. Nine times out of ten, there's one or two giant libraries that shouldn't be there in their entirety: &lt;code&gt;moment.js&lt;/code&gt; with all locale files, an unshaken icon library, a full &lt;code&gt;lodash&lt;/code&gt; import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tree shaking requires ES modules.&lt;/strong&gt; Tree shaking is static dead code elimination — the bundler can only remove exports that are provably unused. This only works with &lt;code&gt;import&lt;/code&gt;/&lt;code&gt;export&lt;/code&gt; syntax. CommonJS &lt;code&gt;require()&lt;/code&gt; is dynamic, so bundlers can't statically analyze it. Libraries that ship CommonJS only are not tree-shakeable. Also critical: barrel files (&lt;code&gt;index.js&lt;/code&gt; that re-exports everything) break tree shaking even for ES module libraries. The bundler sees an import chain to all exports and can't determine what's unused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route-level code splitting.&lt;/strong&gt; &lt;code&gt;React.lazy()&lt;/code&gt; + &lt;code&gt;Suspense&lt;/code&gt; lets you split your bundle at route boundaries so users only download the code for the page they're on:&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;Dashboard&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;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;./pages/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;Settings&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;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;./pages/Settings&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="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;The impact is significant: a 2MB bundle becomes multiple 200-400KB chunks, and users only pay for the chunks they actually navigate to.&lt;/p&gt;

&lt;p&gt;I also mention &lt;strong&gt;vendor splitting&lt;/strong&gt; — separating stable third-party dependencies (React, router, component library) from frequently-changing app code. Stable vendor chunks get long-lived cache headers and don't invalidate when you ship a feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep dive:&lt;/strong&gt; &lt;a href="https://renderlog.in/blog/build-bundles-treeshaking-code-splitting/" rel="noopener noreferrer"&gt;Build Bundles, Tree-shaking, and Code Splitting&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Asset optimization
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; Large images are tanking LCP. Web fonts are causing layout shift. Third-party scripts are blocking the main thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I explain each asset type:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images and LCP.&lt;/strong&gt; The largest contentful paint element is almost always an image. The most impactful single change for LCP is adding &lt;code&gt;fetchpriority="high"&lt;/code&gt; to the hero image and a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. The browser's preload scanner discovers the image earlier and prioritizes its download. Additionally: use WebP or AVIF (30-50% smaller than JPEG at equivalent quality), always set &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; to prevent CLS, and use &lt;code&gt;loading="eager"&lt;/code&gt; on above-fold images (don't lazy-load your LCP element; I've seen this mistake in production).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fonts and CLS.&lt;/strong&gt; Web fonts cause two problems: invisible text during load (FOIT) and layout shift when the font swaps in. &lt;code&gt;font-display: swap&lt;/code&gt; makes text immediately visible using the system fallback font, then swaps to the web font when it loads. This eliminates FOIT but can cause a brief layout shift. &lt;code&gt;font-display: optional&lt;/code&gt; skips the swap entirely if the font isn't cached. There is no layout shift, but users may never see the custom font on a first visit. Which you choose depends on whether the brand cares more about CLS or visual consistency.&lt;/p&gt;

&lt;p&gt;The highest-ROI font optimization most teams skip: &lt;strong&gt;subsetting&lt;/strong&gt;. A full Latin font file might be 400KB. If your content only uses basic Latin characters, a subset can be 30-50KB. &lt;code&gt;pyftsubset&lt;/code&gt; from the fonttools package does this at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party scripts.&lt;/strong&gt; Google Tag Manager, chat widgets, analytics: these are often the biggest main-thread bottleneck on marketing sites. A GTM container that loads 12 tags synchronously in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; will block rendering for hundreds of milliseconds. The fix: load all third-party scripts with &lt;code&gt;async&lt;/code&gt; or &lt;code&gt;defer&lt;/code&gt;, move analytics to server-side where possible, and use the &lt;strong&gt;facade pattern&lt;/strong&gt; for heavy embeds (YouTube, Intercom, maps): show a static thumbnail until the user interacts, then load the real widget on click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep dive:&lt;/strong&gt; &lt;a href="https://renderlog.in/blog/images-fonts-third-party-performance/" rel="noopener noreferrer"&gt;Images, Fonts, and Third-Party Performance&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Long session memory optimization
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The situation:&lt;/strong&gt; The app works fine on first load. After an hour of use, it's sluggish. After three hours, the tab crashes. Lighthouse still scores 100.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I always end with this:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Memory is the area most candidates don't mention at all, which makes bringing it up a reliable signal of production experience. Lighthouse doesn't catch memory problems — it runs a short synthetic test on a clean page. A tab that leaks 20MB per minute will crash in two hours but score perfectly in the lab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leak patterns I've hit in production React apps:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most common one: a WebSocket or SSE subscription set up in a &lt;code&gt;useEffect&lt;/code&gt; that doesn't clean up on unmount. The subscription holds a reference to the component's state setter, which holds a reference to the component's closure, which may hold references to large data structures. The component unmounts and navigation happens, but the subscription keeps firing and accumulates references that the GC can't collect.&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="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;ws&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;WebSocket&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;ws&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;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="nf"&gt;setState&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;parse&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;data&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;ws&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="c1"&gt;// cleanup is critical — without this, you leak&lt;/span&gt;
&lt;span class="p"&gt;},&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;Other patterns: &lt;code&gt;setInterval&lt;/code&gt; without &lt;code&gt;clearInterval&lt;/code&gt; in cleanup, global &lt;code&gt;Map&lt;/code&gt; or &lt;code&gt;Set&lt;/code&gt; caches that grow unboundedly (store 100 items max, then evict the oldest), and third-party library subscriptions that need explicit &lt;code&gt;unsubscribe()&lt;/code&gt; calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For long-running internal tools&lt;/strong&gt; (dashboards that ops teams leave open all day), I also recommend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Listening to the &lt;strong&gt;Page Visibility API&lt;/strong&gt; and pausing heavy polling when the tab is hidden&lt;/li&gt;
&lt;li&gt;Clearing inactive route caches on a timer (TanStack Query's &lt;code&gt;gcTime&lt;/code&gt; does this automatically)&lt;/li&gt;
&lt;li&gt;As a last resort: reload the tab after a configurable idle period (aggressive but effective for kiosk-style deployments)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Deep dives:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://renderlog.in/blog/react-memory-retained-graphs-leaks/" rel="noopener noreferrer"&gt;React Memory Leaks: Subscriptions, Closures, and Retained Graphs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://renderlog.in/blog/javascript-garbage-collection-frontend/" rel="noopener noreferrer"&gt;JavaScript Garbage Collection for Frontend Developers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://renderlog.in/blog/lighthouse-100-still-crashes-memory/" rel="noopener noreferrer"&gt;Lighthouse 100 Still Crashes: Memory, OOM, and Long Sessions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How I put it all together in the interview room
&lt;/h2&gt;

&lt;p&gt;When the question is asked, I don't just list these seven areas — I sequence my answer to show how I actually work through a performance problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clarify the scenario (15–20 seconds).&lt;/strong&gt; “Is this first load, repeat navigation, or long-lived session?” “Mobile, desktop, or both?” “Do we have RUM or only lab?” That signals senior judgment—you are not solving a different company’s app than the one they described.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify the symptom first.&lt;/strong&gt; Slow load? Runtime jank? Crashes after extended use? Each points to a different area from the table above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure before touching code.&lt;/strong&gt; Chrome DevTools Performance panel for runtime, Network panel for requests, Memory panel for heap growth. I always say "I never optimize what I haven't measured."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prioritize by user impact.&lt;/strong&gt; A 500ms LCP improvement on the landing page affects every new visitor. A memory leak fix affects users who stay for hours. Both matter but they have different urgency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go area by area, don't thrash.&lt;/strong&gt; Fixing re-renders while ignoring a 2MB bundle doesn't move the needle. Fixing network while the DOM has 10,000 nodes is partial progress. The seven areas interact — understand which one is the real bottleneck for your specific app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Timeboxing:&lt;/strong&gt; For a &lt;strong&gt;short behavioral question&lt;/strong&gt; (“how do you approach performance?”), I hit the &lt;strong&gt;seven buckets + measurement&lt;/strong&gt; in under two minutes and offer one concrete example from a past project. For a &lt;strong&gt;deep system design or debugging exercise&lt;/strong&gt;, I walk one path end-to-end (e.g. “I’d open Performance, find long tasks, then inspect commit times in React Profiler…”) and name which bucket I ruled out and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;STAR without sounding rehearsed:&lt;/strong&gt; I keep &lt;strong&gt;Situation → bottleneck signal → what I measured → fix → outcome&lt;/strong&gt; in my head. I do not say “STAR” out loud; I just tell the story in that order so the interviewer hears cause, evidence, and trade-offs.&lt;/p&gt;

&lt;p&gt;The interviewer isn't looking for someone who knows every API. They're looking for someone who can look at a slow production app and work through it systematically without panicking. This framework is how I do that.&lt;/p&gt;

&lt;p&gt;For the full index and deep dives, see the &lt;a href="https://renderlog.in/blog/frontend-performance-how-why-hub/" rel="noopener noreferrer"&gt;series hub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Read the original article on Renderlog.in:&lt;br&gt;
&lt;a href="https://renderlog.in/blog/frontend-performance-interview-approach/" rel="noopener noreferrer"&gt;https://renderlog.in/blog/frontend-performance-interview-approach/&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>interview</category>
      <category>react</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
