<?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: Jangwook Kim</title>
    <description>The latest articles on DEV Community by Jangwook Kim (@jangwook_kim_e31e7291ad98).</description>
    <link>https://dev.to/jangwook_kim_e31e7291ad98</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1909290%2F60a8c15f-b2b5-4189-8578-78b8ab78900b.jpg</url>
      <title>DEV Community: Jangwook Kim</title>
      <link>https://dev.to/jangwook_kim_e31e7291ad98</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jangwook_kim_e31e7291ad98"/>
    <language>en</language>
    <item>
      <title>One listener costs you the back button: six bfcache probes</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:40:56 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/one-listener-costs-you-the-back-button-six-bfcache-probes-5868</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/one-listener-costs-you-the-back-button-six-bfcache-probes-5868</guid>
      <description>&lt;p&gt;A page with a &lt;code&gt;beforeunload&lt;/code&gt; listener came back from memory. A page with an &lt;code&gt;unload&lt;/code&gt; listener did not. That's the whole difference: one identifier.&lt;/p&gt;

&lt;p&gt;I keep finding codebases that treat those two as the same thing, filed together under "cleanup on leave." So I built six pages, each carrying exactly one candidate blocker, and drove a real back navigation through each. Here's what the browser handed back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The navigation that never touches the network
&lt;/h2&gt;

&lt;p&gt;Back/forward cache keeps a page alive instead of tearing it down when you navigate away. DOM, JavaScript heap, scroll position, all frozen in memory. Press back and the browser thaws the snapshot. The official framing on web.dev: "Instead of destroying a page when the user navigates away, we postpone destruction and pause JS execution." The payoff: "Loading the previous page is essentially instant, because the entire page can be restored from memory, without having to go to the network at all."&lt;/p&gt;

&lt;p&gt;That last clause carries the weight. A normal back navigation with a warm HTTP cache still reparses the document, re-executes scripts, recalculates layout. A bfcache restore skips all of it. Nothing is recomputed, so no fresh LCP and no fresh layout shift.&lt;/p&gt;

&lt;p&gt;Why bother? Because search-result pogosticking is a real pattern, especially on phones. Read a page, back out, try the next result. If every one of those round trips is a full load, your users experience a slow site no matter how fast your first paint is. And unlike most performance work, this one isn't about writing better code. It's about &lt;strong&gt;removing things that disqualify code you already shipped&lt;/strong&gt;, which makes the effort-to-payoff ratio unusually good.&lt;/p&gt;

&lt;p&gt;Let me lower expectations first. bfcache is not a ranking factor. Nobody, Google included, guarantees you anything in search results for fixing it. This is a navigation-feel problem for real humans.&lt;/p&gt;

&lt;p&gt;The good news is that you never have to guess. Two APIs answer the question directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;event.persisted&lt;/code&gt; on the &lt;code&gt;pageshow&lt;/code&gt; event. &lt;code&gt;true&lt;/code&gt; means the page came out of bfcache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PerformanceNavigationTiming.notRestoredReasons&lt;/code&gt;, which carries the reasons when the page was &lt;strong&gt;not&lt;/strong&gt; restored. Per Chrome's docs, "The &lt;code&gt;notRestoredReasons&lt;/code&gt; API has shipped from Chrome 123 and is being rolled out gradually."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Six pages, one variable each
&lt;/h2&gt;

&lt;p&gt;Mixed variables produce uninterpretable results, so every route below is identical except for its single blocker. Same markup, same instrumentation script.&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;// server.mjs — one route per condition&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;extraHead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang="en"&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;title&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extraHead&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
&amp;lt;script&amp;gt;
window.addEventListener('pageshow', (e) =&amp;gt; {
  const nav = performance.getEntriesByType('navigation')[0];
  window.__bfcache = {
    persisted: e.persisted,
    nrr: nav &amp;amp;&amp;amp; nav.notRestoredReasons
      ? JSON.parse(JSON.stringify(nav.notRestoredReasons))
      : null,
  };
});
&amp;lt;/script&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&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;routes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/clean&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clean&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;&amp;lt;p&amp;gt;no blockers&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/nostore&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&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;no-store&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nostore&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="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;/unload&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unload&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="p"&gt;,&lt;/span&gt;
                      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;script&amp;gt;window.addEventListener("unload", function(){});&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/beforeunload&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeunload&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="p"&gt;,&lt;/span&gt;
                      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;script&amp;gt;window.addEventListener("beforeunload", function(e){});&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/websocket&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;websocket&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="p"&gt;,&lt;/span&gt;
                      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;script&amp;gt;window.__ws = new WebSocket("ws://127.0.0.1:8099");&amp;lt;/script&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/next&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&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;&amp;lt;p&amp;gt;second page&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;JSON.parse(JSON.stringify(...))&lt;/code&gt; round trip isn't decoration. &lt;code&gt;notRestoredReasons&lt;/code&gt; doesn't hand you a plain object, and logging it directly gets you &lt;code&gt;[object Object]&lt;/code&gt;. That cost me the first run.&lt;/p&gt;

&lt;p&gt;Each probe follows the same four steps. Open the target page, navigate to &lt;code&gt;/next&lt;/code&gt;, go back through history, read &lt;code&gt;window.__bfcache&lt;/code&gt; on the restored page. Chrome 150 on macOS, driven over the DevTools protocol rather than by hand. The last probe swaps the sandbox for a page on this live blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two blocked, four restored
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page condition&lt;/th&gt;
&lt;th&gt;&lt;code&gt;event.persisted&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;notRestoredReasons.reasons&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no blockers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; (restored)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cache-Control: no-store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; (restored)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;beforeunload&lt;/code&gt; listener&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; (restored)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;unload&lt;/code&gt; listener&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;false&lt;/code&gt; (blocked)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[{ reason: "masked" }]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open WebSocket&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;false&lt;/code&gt; (blocked)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[{ reason: "websocket" }]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;live blog article&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;true&lt;/code&gt; (restored)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..%2F..%2F..%2Fassets%2Fblog%2Fbfcache-notrestoredreasons-audit-2026%2Fprobe-results.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/..%2F..%2F..%2Fassets%2Fblog%2Fbfcache-notrestoredreasons-audit-2026%2Fprobe-results.png" alt="Measured bfcache restore status and notRestoredReasons per condition" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;unload&lt;/code&gt; result is the expected one, and the official guidance doesn't hedge about it. "Never use the &lt;code&gt;unload&lt;/code&gt; event. Ever!" With the mechanism spelled out right after: "On desktop, Chrome and Firefox have chosen to make pages ineligible for bfcache if they add an &lt;code&gt;unload&lt;/code&gt; listener."&lt;/p&gt;

&lt;p&gt;Note what my handler contained: nothing. An empty function body still disqualified the page. The browser checks whether a listener is &lt;strong&gt;registered&lt;/strong&gt;, not whether it does anything. That's worth knowing before you go hunting, because a &lt;code&gt;grep&lt;/code&gt; that only looks for suspicious-looking teardown code will miss the dead ones.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;beforeunload&lt;/code&gt;, meanwhile, sailed through. If your team files both under one mental category, that category needs splitting. Where you genuinely need teardown, use &lt;code&gt;pagehide&lt;/code&gt;, which as the docs put it "fires in all cases where the &lt;code&gt;unload&lt;/code&gt; event fires, and it also fires when a page is put in the bfcache."&lt;/p&gt;

&lt;h2&gt;
  
  
  "masked" is not an answer
&lt;/h2&gt;

&lt;p&gt;Now for the API's practical ceiling. The WebSocket page returned a usable string, &lt;code&gt;"websocket"&lt;/code&gt;. The &lt;code&gt;unload&lt;/code&gt; page returned &lt;code&gt;"masked"&lt;/code&gt; — on a document containing zero iframes.&lt;/p&gt;

&lt;p&gt;Chrome's documentation explains the value like this: "For all the cross-origin iframes, we report &lt;code&gt;null&lt;/code&gt; for the &lt;code&gt;reasons&lt;/code&gt; value for the frame, and the top-level frame will shows a reason of &lt;code&gt;"masked"&lt;/code&gt;." Reasonable as a privacy measure. But read the sentence that follows: "&lt;code&gt;"masked"&lt;/code&gt; may also be used for user agent-specific reasons so may not always indicate an issue in an iframe."&lt;/p&gt;

&lt;p&gt;My run was exactly that case. Single document, no frames, cause unambiguously the &lt;code&gt;unload&lt;/code&gt; listener, and the field still refused to name it.&lt;/p&gt;

&lt;p&gt;So here's the position I've landed on. &lt;strong&gt;Treat &lt;code&gt;notRestoredReasons&lt;/code&gt; as a tool that tells you which URLs are failing, not why.&lt;/strong&gt; Collect it from real users, rank templates by block rate, then reproduce the worst offender locally with the DevTools Back/forward cache panel to find the actual cause. Teams that expect field data to work as a diagnosis will stall the first time &lt;code&gt;"masked"&lt;/code&gt; shows up in ninety percent of their rows. That's not a flaw to complain about, it's the grain of the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;no-store&lt;/code&gt; stopped being a death sentence
&lt;/h2&gt;

&lt;p&gt;The result I didn't expect: the &lt;code&gt;Cache-Control: no-store&lt;/code&gt; page restored. That contradicts a rule most of us internalized years ago, and web.dev records the old behavior plainly. With &lt;code&gt;no-store&lt;/code&gt; present, "browsers have chosen not to store the page in bfcache."&lt;/p&gt;

&lt;p&gt;The same article carries the sequel, though: "There is work underway to change this behavior for Chrome in a privacy-preserving manner." That work has landed. Chrome's own page on enabling bfcache for &lt;code&gt;Cache-Control: no-store&lt;/code&gt; documents the rollout reaching 100% of users across March and April 2025. My probe just confirms it in the field.&lt;/p&gt;

&lt;p&gt;The conditions matter here, so let me copy them across carefully. Per Chrome's official description, a &lt;code&gt;no-store&lt;/code&gt; page may enter bfcache, but &lt;strong&gt;Chrome evicts it when cookies or other authorization state change&lt;/strong&gt;, specifically so that a logged-out user can't reach a logged-in view by pressing back. Certain API usage still makes &lt;code&gt;no-store&lt;/code&gt; pages ineligible outright. And if such a page issues a fetch or XHR whose response also carries &lt;code&gt;no-store&lt;/code&gt;, that evicts the page too, since the response may contain sensitive data.&lt;/p&gt;

&lt;p&gt;The practical read: using &lt;code&gt;no-store&lt;/code&gt; as your bfcache opt-out no longer rests on much. That doesn't mean sensitive pages now sit exposed in memory, either. Responsibility moved from a header string to a sharper signal, the change in authentication state. I won't push the conclusion harder than that, since it's browser behavior and it moved once already. But if you have code built on "we send &lt;code&gt;no-store&lt;/code&gt;, so we're obviously not cached," go measure it again this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  An open connection blocks, WebSocket code doesn't
&lt;/h2&gt;

&lt;p&gt;I got the WebSocket probe wrong the first time. The initial version pointed at &lt;code&gt;ws://127.0.0.1:8099&lt;/code&gt; with nothing listening on that port. Result: &lt;code&gt;persisted: true&lt;/code&gt;. Not blocked at all.&lt;/p&gt;

&lt;p&gt;Obvious in hindsight. The handshake failed instantly, so by the time I pressed back, the page held no open connection. I started a real WebSocket server, logged &lt;code&gt;readyState&lt;/code&gt; before navigating away to confirm it was &lt;code&gt;1&lt;/code&gt; (OPEN), and reran. That produced &lt;code&gt;persisted: false&lt;/code&gt; with &lt;code&gt;reasons: [{ reason: "websocket" }]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The mistake turned out to be the most useful part of the day. What the browser inspects is the set of &lt;strong&gt;connections open at navigation time&lt;/strong&gt;, not whether the word &lt;code&gt;WebSocket&lt;/code&gt; appears in your bundle. The same logic covers the rest of the connection-shaped blockers. The official list is open IndexedDB connections, in-flight &lt;code&gt;fetch()&lt;/code&gt; or XHR, and open WebSocket or WebRTC connections, with a consistent recommendation: close them during &lt;code&gt;pagehide&lt;/code&gt; or &lt;code&gt;freeze&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nobody is asking you to drop real-time features. Scope the connection's lifetime to the page's visibility instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;socket&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;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;socket&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wss://example.com/live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Close on the way out — pagehide, not unload.&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;pagehide&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="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Reconnect and refresh whatever went stale while frozen.&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;pageshow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;persisted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;refreshStaleUI&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;Don't skip that &lt;code&gt;event.persisted&lt;/code&gt; branch. A restored page shows the exact frame the user walked away from. Cart quantities, stock counts, notification badges, session timers: anything that decays needs refetching right there. Otherwise you've traded correctness for speed, which is a bad trade dressed up as a performance win.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;window.opener&lt;/code&gt; belongs in the same family. The docs are blunt: "A page with a non-null &lt;code&gt;window.opener&lt;/code&gt; reference can't safely be put into bfcache." Most of us add &lt;code&gt;rel="noopener"&lt;/code&gt; for security reasons; it happens to protect eligibility too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Collecting reasons from the field
&lt;/h2&gt;

&lt;p&gt;Local probes reproduce well but cover almost nothing. Real browsers, real extensions, real network conditions produce blockers you'll never stage. Drop this in to start collecting which URLs lose the restore.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pageshow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nav&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="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// Restored from bfcache — count it as a hit.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;persisted&lt;/span&gt;&lt;span class="p"&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="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/rum/bfcache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&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="nx"&gt;pathname&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="c1"&gt;// Only back/forward navigations that failed to restore.&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;nav&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;back_forward&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notRestoredReasons&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;nrr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notRestoredReasons&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="nf"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/rum/bfcache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;hit&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="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nrr&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="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;||&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nrr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="o"&gt;||&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;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If most of the &lt;code&gt;reasons&lt;/code&gt; values come back &lt;code&gt;"masked"&lt;/code&gt;, the data is still doing its job. Its value lives in the URL distribution, not the strings.&lt;/p&gt;

&lt;p&gt;One more trap when you read the numbers. Right after the live blog page restored, its navigation entry still reported &lt;code&gt;type: "navigate"&lt;/code&gt;, &lt;code&gt;duration: 1315.2&lt;/code&gt;, &lt;code&gt;transferSize: 22218&lt;/code&gt;. Those are the &lt;strong&gt;original load's&lt;/strong&gt; figures. A bfcache restore doesn't create a new navigation entry, so &lt;code&gt;nav.duration&lt;/code&gt; will never tell you how fast the restore was. Judge restores by &lt;code&gt;event.persisted&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;The last step is the one that makes the fix survive future commits: automate it. The shape is the same as &lt;a href="https://dev.to/en/blog/en/validate-structured-data-ci-jsonld-2026"&gt;wiring JSON-LD validation into CI as a build gate&lt;/a&gt;. Six probes of open-navigate-back-read translate cleanly into a headless browser script you run against your key templates on every deploy. And where &lt;a href="https://dev.to/en/blog/en/content-visibility-auto-render-cost-measure-2026"&gt;measuring &lt;code&gt;content-visibility&lt;/code&gt; render cost&lt;/a&gt; dealt with the first paint, this work covers every navigation after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change this week
&lt;/h2&gt;

&lt;p&gt;Two of six probes failed. One died to a single registered listener, the other to a single open socket. The other four passed, including the one everybody still assumes is disqualified.&lt;/p&gt;

&lt;p&gt;In the order I'd actually do them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit and delete every &lt;code&gt;unload&lt;/code&gt; listener.&lt;/strong&gt; Empty bodies block too. Search third-party snippets, not just your own code, and grep for &lt;code&gt;onunload&lt;/code&gt; alongside &lt;code&gt;addEventListener('unload'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move teardown to &lt;code&gt;pagehide&lt;/code&gt;.&lt;/strong&gt; It fires everywhere &lt;code&gt;unload&lt;/code&gt; fires, plus on entry to bfcache. Leave &lt;code&gt;beforeunload&lt;/code&gt; alone; confirmation dialogs are not the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close WebSocket, WebRTC and IndexedDB connections in &lt;code&gt;pagehide&lt;/code&gt;.&lt;/strong&gt; The test is what's open when the user leaves, not what's in the bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an &lt;code&gt;event.persisted === true&lt;/code&gt; branch to &lt;code&gt;pageshow&lt;/code&gt;.&lt;/strong&gt; Reconnect, then refetch anything that goes stale. Without it you ship a fast, wrong screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put &lt;code&gt;rel="noopener"&lt;/code&gt; on outbound links.&lt;/strong&gt; A non-null &lt;code&gt;window.opener&lt;/code&gt; disqualifies the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-measure anything built on &lt;code&gt;no-store&lt;/code&gt; assumptions.&lt;/strong&gt; Chrome changed this in 2025, with eviction on cookie and auth changes as the guardrail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument the field, but read the URLs.&lt;/strong&gt; Reasons get masked. Block-rate-by-template is the signal you're after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest boundary: six probes, one machine, one Chrome build. Safari and Firefox draw their eligibility lines differently, and &lt;code&gt;notRestoredReasons&lt;/code&gt; is a Chromium API to begin with. Don't read these strings as a spec guarantee. The procedure is cheap enough to rerun, though, so point it at the browsers your users actually have and get your own numbers.&lt;/p&gt;




&lt;p&gt;Whether your site's back navigations really restore from cache, and which templates quietly lose eligibility, is a measurable question rather than a matter of taste. I take on this kind of work personally: measuring the current state, fixing what the measurement exposes, then leaving behind a CI gate so the fix doesn't silently regress twenty commits later. If that's useful to you, &lt;a href="https://dev.to/en/contact"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>corewebvitals</category>
      <category>webdev</category>
      <category>chrome</category>
    </item>
    <item>
      <title>1,248 Feed Items, Four Languages, Zero Language Metadata</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 06:41:20 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/1248-feed-items-four-languages-zero-language-metadata-2co4</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/1248-feed-items-four-languages-zero-language-metadata-2co4</guid>
      <description>&lt;p&gt;My blog ships one aggregate RSS feed. I opened the built file and counted: 1,248 items, Korean, Japanese, English and Chinese posts interleaved by publication date. Nowhere in that XML does anything say which language a given item is written in. Not on the channel. Not on any item.&lt;/p&gt;

&lt;p&gt;So how does a consumer figure it out? It looks at the characters and guesses.&lt;/p&gt;

&lt;p&gt;The W3C Internationalization Working Group published two First Public Working Drafts on 2026-07-16. One of them, &lt;a href="https://www.w3.org/TR/string-meta/" rel="noopener noreferrer"&gt;Strings on the Web: Language and Direction Metadata&lt;/a&gt;, goes straight at that guessing habit. The companion draft, &lt;a href="https://www.w3.org/TR/charmod-norm/" rel="noopener noreferrer"&gt;Character Model for the World Wide Web: String Matching&lt;/a&gt;, covers the adjacent problem of comparing strings across normalization forms. I read the first one, audited my own site against it, fixed one thing, and bolted a build gate on so it can't rot back. Every number below is real output from that process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strings forget their language the moment they leave HTML
&lt;/h2&gt;

&lt;p&gt;Start with why this matters at all, because skipping it makes the measurements later look like pedantic XML nitpicking.&lt;/p&gt;

&lt;p&gt;HTML has &lt;code&gt;lang&lt;/code&gt; and &lt;code&gt;dir&lt;/code&gt;. Write &lt;code&gt;&amp;lt;p lang="ar" dir="rtl"&amp;gt;&lt;/code&gt; and the fact that this paragraph is Arabic and reads right-to-left is baked into the markup itself. The browser renders accordingly. A screen reader picks the Arabic voice engine instead of mangling the text through an English one. Search engines get a declared answer instead of a statistical one. Markup languages were built with extensible attributes, so there has always been somewhere to hang this information.&lt;/p&gt;

&lt;p&gt;The trouble is that strings on the modern web don't travel through HTML alone. They move through JSON API responses, JSON-LD blocks, RSS and Atom feeds, WebIDL interfaces, config files, message queues. Most of those formats were designed on the assumption that a string is just a string. There is no slot in &lt;code&gt;{"title": "..."}&lt;/code&gt; for saying what language that title is in. The draft puts it plainly: JSON, WebIDL and other non-markup data languages generally do not provide extensible attributes, and were not conceived with built-in language or direction metadata.&lt;/p&gt;

&lt;p&gt;So the metadata evaporates. A string authored in a CMS with a perfectly good &lt;code&gt;lang&lt;/code&gt; attribute gets serialized to JSON, handed to an API, passed to a frontend, and re-rendered into HTML somewhere else entirely. Nobody carried the language along. Whatever component does the final render is left with one option, and it's the bad one.&lt;/p&gt;

&lt;p&gt;Here's the requirement the draft states:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For any string field containing natural language text, it &lt;em&gt;MUST&lt;/em&gt; be possible to determine the language and string direction of that specific string. Such determination &lt;em&gt;SHOULD&lt;/em&gt; use metadata at the string or document level and &lt;em&gt;SHOULD NOT&lt;/em&gt; depend on heuristics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The abstract sets the tone before that: "This document describes the best practices for identifying the language and direction for strings used on the Web."&lt;/p&gt;

&lt;p&gt;Concretely, the draft recommends a small set of moves. For a single-language field, carry a &lt;code&gt;{value, lang, dir}&lt;/code&gt; triple instead of a bare string. Allow document-level &lt;code&gt;language&lt;/code&gt; and &lt;code&gt;direction&lt;/code&gt; defaults, but require that string-level metadata be able to override them. For fields that hold several translations at once, use a language map keyed by language tag. Restrict direction to exactly three values: &lt;code&gt;ltr&lt;/code&gt;, &lt;code&gt;rtl&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;. And for JSON-LD specifically, declare &lt;code&gt;@language&lt;/code&gt; and &lt;code&gt;@direction&lt;/code&gt; in &lt;code&gt;@context&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat belongs right here, up front, before I build anything on top of this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a draft document and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to cite this document as other than a work in progress.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;dir="auto"&lt;/code&gt; actually does, and where it breaks
&lt;/h2&gt;

&lt;p&gt;The heuristic the draft is warning about has a name: first-strong. Scan a string character by character, find the first character with a strong directional property, and adopt its direction. That's what &lt;code&gt;dir="auto"&lt;/code&gt; implements in browsers. It costs nothing and it's usually right, which is exactly why it spreads.&lt;/p&gt;

&lt;p&gt;The draft is specific about how it fails:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The main problem with this approach is that it produces the wrong result for (1) strings that begin with a strong character with a different directionality than that needed for the string overall (eg. an Arabic tweet that starts with a hashtag) (2) strings that don't have a strong directional character (such as a telephone number), which are likely to be displayed incorrectly in a RTL context.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reading a failure description isn't the same as watching it happen, so I implemented first-strong myself and ran it against strings I built to probe those exact edges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;

&lt;span class="n"&gt;ISOLATE_INIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⁦&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⁧&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⁨&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# LRI, RLI, FSI
&lt;/span&gt;&lt;span class="n"&gt;PDI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⁩&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;first_strong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ISOLATE_INIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;PDI&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&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="n"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bidirectional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ltr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;R&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rtl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ltr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourteen test strings, each tagged with the direction I declared correct as the author. The run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;case                                         declared  dir=auto  ok
------------------------------------------------------------------------
ar title starting with latin product name    rtl       ltr       MISMATCH
he title starting with a version number      rtl       rtl       OK
ar UI label starting with a digit            rtl       rtl       OK
he string starting with an ASCII quote       rtl       ltr       MISMATCH
ar author name with latin handle first       rtl       ltr       MISMATCH
en title starting with an arabic loanword    ltr       rtl       MISMATCH
ar plain sentence                            rtl       rtl       OK
he plain sentence                            rtl       rtl       OK
ko plain sentence                            ltr       ltr       OK
ja plain sentence                            ltr       ltr       OK
zh plain sentence                            ltr       ltr       OK
en plain sentence                            ltr       ltr       OK
numeric-only string (no strong char)         ltr       ltr       OK
emoji-led ar string                          rtl       rtl       OK
------------------------------------------------------------------------
total=14  mismatch=4  mismatch_rate=28.6%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four wrong out of fourteen. Every failure has the same shape: Latin-script text sitting in front of right-to-left content, or the mirror case where an Arabic word opens an English sentence. An Arabic article title that leads with a Latin product name. A Hebrew string opening on an ASCII quotation mark. An Arabic author byline where the &lt;code&gt;@handle&lt;/code&gt; comes first. And in reverse, an English title beginning with an Arabic loanword, which first-strong flips to RTL.&lt;/p&gt;

&lt;p&gt;The passes taught me more than the failures did. A Hebrew title starting with a version number resolved correctly. So did an Arabic UI label beginning with a digit, and an Arabic string led by an emoji. Digits, punctuation and emoji carry no strong directional property, so the scan skips right over them and lands on the first real letter. My working intuition before running this was "ASCII in front breaks it." Wrong. Strong Latin &lt;em&gt;letters&lt;/em&gt; in front break it. That distinction changes which of your fields are actually at risk.&lt;/p&gt;

&lt;p&gt;About that 28.6%: it is the mismatch rate of this constructed fourteen-case set and nothing more. I picked the cases to stress the boundary, so the number is not a web-wide statistic and I'm not going to pretend otherwise. What the run establishes is the shape of the failure, not its frequency in the wild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing my own build output
&lt;/h2&gt;

&lt;p&gt;Reading a spec and nodding along is cheap. So I wrote a script that walks &lt;code&gt;dist/&lt;/code&gt; after a production build and reports what my site declares rather than what I assume it declares. Real output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== pages audited == 1248
html[lang] present : 1248/1248
html[dir]  present : 0/1248

== JSON-LD ==
blocks parsed          : 1248  (parse errors: 0)
@context has @language : 0
@context has @direction: 0
blocks containing inLanguage: 1248

== feeds ==
  rss-en.xml    xml:lang=False  &amp;lt;language&amp;gt;=True   items=312
  rss-ja.xml    xml:lang=False  &amp;lt;language&amp;gt;=True   items=312
  rss-ko.xml    xml:lang=False  &amp;lt;language&amp;gt;=True   items=312
  rss-zh.xml    xml:lang=False  &amp;lt;language&amp;gt;=True   items=312
  rss.xml       xml:lang=False  &amp;lt;language&amp;gt;=False  items=1248
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTML layer held up. Every page declares &lt;code&gt;html[lang]&lt;/code&gt;, and every JSON-LD block carries &lt;code&gt;inLanguage&lt;/code&gt; at the item level. That's the payoff from earlier passes over this site, including the &lt;a href="https://dev.to/en/blog/en/hreflang-reciprocity-audit-multilingual-2026"&gt;hreflang reciprocity audit&lt;/a&gt; that forced me to get the per-page language story straight in the first place.&lt;/p&gt;

&lt;p&gt;The last line is the problem. &lt;code&gt;dist/rss.xml&lt;/code&gt; is the aggregate feed, the one that mixes all four language editions into a single chronological stream, and it declares nothing. No channel &lt;code&gt;&amp;lt;language&amp;gt;&lt;/code&gt;. No per-item language. Twelve hundred and forty-eight items going out the door with their language stripped off, to be reconstructed by whatever guesswork the consumer felt like implementing.&lt;/p&gt;

&lt;p&gt;The per-language feeds were fine, which is exactly why I'd never looked closer. &lt;code&gt;rss-ko.xml&lt;/code&gt; says &lt;code&gt;ko&lt;/code&gt;, &lt;code&gt;rss-ja.xml&lt;/code&gt; says &lt;code&gt;ja&lt;/code&gt;, and that felt like the job being done. The aggregate feed had been sitting there for as long as the site has been multilingual and I had never once considered it a defect. It took a script that didn't share my assumptions to surface it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the mixed feed with &lt;code&gt;dc:language&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;RSS 2.0 has a channel-level &lt;code&gt;&amp;lt;language&amp;gt;&lt;/code&gt; element and no per-item equivalent. For a feed where every item is a different language, channel-level is useless. You cannot declare one language for a stream that has four.&lt;/p&gt;

&lt;p&gt;Dublin Core solves this. &lt;code&gt;dc:language&lt;/code&gt; is defined per-element, which is precisely the granularity the draft asks for: metadata attached to the string, not inferred from context. &lt;code&gt;@astrojs/rss&lt;/code&gt; supports both an &lt;code&gt;xmlns&lt;/code&gt; option for declaring the namespace and per-item &lt;code&gt;customData&lt;/code&gt; for arbitrary child elements, so the change is small.&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;return&lt;/span&gt; &lt;span class="nf"&gt;rss&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SITE_TITLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SITE_DESCRIPTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;xmlns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://purl.org/dc/elements/1.1/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&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;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;slugParts&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slugParts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&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;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&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;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;pubDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&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;pubDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="na"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;dc:language&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/dc:language&amp;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 language code already lives in the content collection ID, since posts are stored as &lt;code&gt;ko/slug&lt;/code&gt;, &lt;code&gt;ja/slug&lt;/code&gt; and so on. Splitting on the first slash gives both the language tag and the slug, and I need the slug for the link anyway. Rebuild, then count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;dc:language&amp;gt;[a-z]*&amp;lt;/dc:language&amp;gt;'&lt;/span&gt; dist/rss.xml | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
 312 &amp;lt;dc:language&amp;gt;en&amp;lt;/dc:language&amp;gt;
 312 &amp;lt;dc:language&amp;gt;ja&amp;lt;/dc:language&amp;gt;
 312 &amp;lt;dc:language&amp;gt;ko&amp;lt;/dc:language&amp;gt;
 312 &amp;lt;dc:language&amp;gt;zh&amp;lt;/dc:language&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero to 1,248. Even distribution across four languages, which is the expected shape given the site publishes every post in all four.&lt;/p&gt;

&lt;p&gt;Be clear about what this buys. &lt;code&gt;dc:language&lt;/code&gt; serves feed readers, aggregators and anything else consuming the XML programmatically. It is not a search ranking signal, and I'm making no traffic claim here. Google's position on structured data has been consistent for years: it helps machines understand the content, it does not guarantee a ranking outcome. Language metadata sits in the same category. It makes the data honest. That's the whole return.&lt;/p&gt;

&lt;h2&gt;
  
  
  An unverified gate is not a gate
&lt;/h2&gt;

&lt;p&gt;Fixing a thing once means it's fixed once. The interesting question is whether it's still fixed in three months after twenty commits touch the feed code.&lt;/p&gt;

&lt;p&gt;I saved the checker as &lt;code&gt;scripts/validate-string-meta.mjs&lt;/code&gt; and hung it off postbuild next to the hreflang validator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"postbuild"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node scripts/validate-hreflang.mjs &amp;amp;&amp;amp; node scripts/validate-string-meta.mjs"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then came the part people skip. A checker that has only ever printed "pass" has proven nothing about its ability to print "fail." I deleted three &lt;code&gt;dc:language&lt;/code&gt; entries from the built &lt;code&gt;rss.xml&lt;/code&gt; by hand and reran it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;html[lang]: 1288/1288 pages
per-language feeds: 4 checked
mixed feed rss.xml: 1245/1248 items declare dc:language
FAILED: rss.xml: dc:language 1245 / item 1248 — mismatch
exit=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caught all three, correct count, nonzero exit. Restoring the file made it pass again. Two minutes of work to convert "I believe this gate works" into "I watched this gate fail on purpose." Every validator I add to this pipeline gets that treatment now, because a gate that can't fail is decoration with a runtime cost. That habit came out of the &lt;a href="https://dev.to/en/blog/en/multilingual-blog-technical-audit-campaign-2026"&gt;five-day audit campaign&lt;/a&gt; where I ran measure, fix, and freeze-as-a-gate on repeat, and it has caught more than one script that was quietly passing on a typo'd selector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not add &lt;code&gt;@language&lt;/code&gt; to my JSON-LD context
&lt;/h2&gt;

&lt;p&gt;This is the decision I spent the most time on, and I landed against the draft's recommendation.&lt;/p&gt;

&lt;p&gt;The draft says to declare language and direction in the JSON-LD &lt;code&gt;@context&lt;/code&gt;. My site currently emits &lt;code&gt;"@context": "https://schema.org"&lt;/code&gt; as a plain string, with an &lt;code&gt;@graph&lt;/code&gt; array underneath. The upgrade looks like a two-minute edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@vocab"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ltr"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@graph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't ship it. Here's why.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;@graph&lt;/code&gt; holds site-wide entities that appear identically on every language edition. Pull up any Korean page and you'll find this inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Organization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jangwook.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Personal technology blog by Kim Jangwook"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those strings are English. They're English on the Korean page, the Japanese page, and the Chinese page, because I write the organization description once and reuse it. If I set a document-level &lt;code&gt;@language&lt;/code&gt; matching the page language, every one of those global entity strings gets labeled &lt;code&gt;ko&lt;/code&gt; on Korean pages and &lt;code&gt;ja&lt;/code&gt; on Japanese pages. I'd be attaching a confident, machine-readable, verifiably false claim to a string that hasn't changed.&lt;/p&gt;

&lt;p&gt;Missing metadata and wrong metadata are not the same failure. When metadata is absent, a consumer knows it's operating on inference and can hedge. When metadata is present and wrong, it gets believed. Wrong is worse than absent, and it's worse in a way that's harder to detect downstream because everything looks well-formed.&lt;/p&gt;

&lt;p&gt;There's a second reason, which is that the audit already told me I have a more precise layer in place: &lt;code&gt;inLanguage&lt;/code&gt; on all 1,248 blocks, at the item level, where it actually varies. A document-wide default would be a coarser statement sitting on top of a finer one. The &lt;a href="https://dev.to/en/blog/en/json-ld-graph-entity-linking-2026"&gt;&lt;code&gt;@graph&lt;/code&gt; consolidation work&lt;/a&gt; is what put that item-level coverage there, and it would be strange to now paper over it with a blunter declaration.&lt;/p&gt;

&lt;p&gt;Two routes are defensible. Convert the global entity strings to the draft's language-map form, so each one declares its own language regardless of which page it lands on. Or leave &lt;code&gt;@context&lt;/code&gt; untouched and let per-item &lt;code&gt;inLanguage&lt;/code&gt; carry the load. I took the second, for now, because the first depends on the draft's field-name conventions settling down. This is a First Public Working Draft. Those names may not survive it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm leaving broken on purpose
&lt;/h2&gt;

&lt;p&gt;Three open items, stated plainly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;html[dir]&lt;/code&gt; is missing on 1,248 of 1,248 pages and I left it that way. Korean, Japanese, English and Chinese are all left-to-right, the browser default is &lt;code&gt;ltr&lt;/code&gt;, and the rendering is correct today. The declaration is implicit rather than wrong. That changes the day I add an RTL edition, and on that day one more rule goes into the gate. Writing &lt;code&gt;dir="ltr"&lt;/code&gt; on twelve hundred pages right now would be motion, not progress.&lt;/p&gt;

&lt;p&gt;The draft is a first draft. Field names and recommendations can change before it becomes a Recommendation, so designing a new API schema strictly against it would be premature. What is not premature is the underlying principle. Attaching language metadata to strings predates this document by a long stretch, and doing it with an established mechanism like &lt;code&gt;dc:language&lt;/code&gt; is safe regardless of where the draft ends up.&lt;/p&gt;

&lt;p&gt;Last one, and it's the limit I'm least comfortable with. I don't read Arabic or Hebrew. Every RTL verdict in the experiment above came from computing Unicode character properties, not from a native speaker looking at rendered output. Direction resolution is defined in terms of character properties, so computation is the right tool for deciding what the algorithm returns. Whether the result looks natural on screen to someone who reads the script daily is outside what I can vouch for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven things to check in your own build output
&lt;/h2&gt;

&lt;p&gt;The short version: the W3C draft asks that every natural-language string be able to declare its own language and direction without heuristics. I implemented the heuristic it warns about and watched it miss 4 of 14 constructed cases, all of them Latin-letter prefixes on RTL content or the reverse. I audited my built site, found an aggregate RSS feed shipping 1,248 items with zero language metadata, fixed it with per-item &lt;code&gt;dc:language&lt;/code&gt;, and wired a validator into postbuild that I verified by breaking the file on purpose. I also declined the draft's JSON-LD advice, because applying it to my &lt;code&gt;@graph&lt;/code&gt; would attach wrong language tags to English entity strings on non-English pages.&lt;/p&gt;

&lt;p&gt;Run these against your own &lt;code&gt;dist/&lt;/code&gt;, not your source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Count &lt;code&gt;html[lang]&lt;/code&gt; coverage across every built page. Coverage on the templates you remember is not coverage.&lt;/li&gt;
&lt;li&gt;Find every string field crossing a JSON boundary and ask whether the language survives the trip. API responses, feeds, structured data blobs, config.&lt;/li&gt;
&lt;li&gt;Grep for &lt;code&gt;dir="auto"&lt;/code&gt; and check whether any of those fields can start with a strong letter from the opposite script. Digits, punctuation and emoji are fine; letters are not.&lt;/li&gt;
&lt;li&gt;If you emit a mixed-language feed, put &lt;code&gt;dc:language&lt;/code&gt; on each item. RSS 2.0 gives you nothing per-item, so pull in the Dublin Core namespace.&lt;/li&gt;
&lt;li&gt;Check whether your JSON-LD carries item-level &lt;code&gt;inLanguage&lt;/code&gt; before you reach for a document-level &lt;code&gt;@language&lt;/code&gt; default, and check what's actually in your &lt;code&gt;@graph&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;Never set a language tag on a string you haven't confirmed. Absent metadata makes consumers cautious. Wrong metadata gets trusted.&lt;/li&gt;
&lt;li&gt;Break your own validator on purpose and confirm it exits nonzero. Then restore and confirm it passes.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If your site serves several languages and you're not certain what its build output actually declares, that's a measurable question rather than a matter of opinion. I take on consulting and implementation work in this area personally: auditing language and direction metadata across pages, feeds and structured data, then turning whatever the audit finds into CI gates so the fix survives the next twenty commits. If that's useful to you, &lt;a href="https://dev.to/en/contact"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>w3c</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GPT-5.6 Prompt Caching: The 24-Hour Cache Is Gone</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 01:22:38 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/gpt-56-prompt-caching-the-24-hour-cache-is-gone-546c</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/gpt-56-prompt-caching-the-24-hour-cache-is-gone-546c</guid>
      <description>&lt;p&gt;Say your product sends the same long block of instructions to an AI model on every request. A returns policy. A tone-of-voice sheet. A product catalogue extract. That block doesn't change between customers. You still paid for it on every single call, the way you'd pay a narrator to read the same chapter aloud to every visitor who walks in.&lt;/p&gt;

&lt;p&gt;Prompt caching is the fix. The provider keeps the unchanging front section of your request in fast storage and charges you a fraction of the normal rate to reuse it. On OpenAI's GPT-5.5 models that reuse window stretched to 24 hours by default. Then GPT-5.6 shipped, and the rules changed underneath the feature.&lt;/p&gt;

&lt;p&gt;We ran the API on both model families to see exactly what changed. One finding matters more than the rest: on GPT-5.6 the 24-hour window is gone, replaced by 30 minutes. And filling the cache is itself a billable line item, priced above plain input.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed between the two model families
&lt;/h2&gt;

&lt;p&gt;OpenAI's API changelog is short about this. The July 9, 2026 entry lists "explicit prompt caching controls" as part of the GPT-5.6 release. The May 29, 2026 entry says that for organizations without zero-data-retention, cache lifetime defaults to 24 hours instead of a few minutes in memory.&lt;/p&gt;

&lt;p&gt;Read together, those two entries suggest the feature only got better. The documentation tells a different story. GPT-5.6 introduces a new &lt;code&gt;prompt_cache_options&lt;/code&gt; block next to the older &lt;code&gt;prompt_cache_retention&lt;/code&gt; setting, plus a way to mark exactly where the reusable section of your prompt ends instead of letting the system guess. That marker is the "explicit control" the changelog means.&lt;/p&gt;

&lt;p&gt;What the changelog does not mention: the new options block accepts a much shorter lifetime, and GPT-5.6 reports cache writes as their own billable quantity. We wanted both confirmed by the API itself rather than inferred from a docs page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built and ran
&lt;/h2&gt;

&lt;p&gt;Effloow Lab ran an OpenAI API check on 2026-07-20 using &lt;code&gt;scripts/gpt56-explicit-cache-probe.py&lt;/code&gt;, a small budget-guarded script in this repository.&lt;/p&gt;

&lt;p&gt;The setup is deliberately boring. We wrote a 9,208-character fictional operations handbook (invented warehouse rules, no real company, no customer data) and sent it as the unchanging front section of every request, followed by one short question about it. That prefix measured 2,032 tokens on arrival, comfortably over the 1,024-token minimum OpenAI documents for caching to engage at all.&lt;/p&gt;

&lt;p&gt;Then four configurations, same text every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A&lt;/strong&gt;: GPT-5.6 with caching left on automatic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B&lt;/strong&gt;: GPT-5.6 with the explicit mode switched on and the end of the handbook marked as the cache boundary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2&lt;/strong&gt;: same as B, but asking for a 24-hour cache lifetime&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C&lt;/strong&gt;: GPT-5.5 with the old 24-hour retention setting, as a control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four calls per configuration, roughly two seconds apart. Every call passed through the repository's token budget guard before and after. The full commands, raw console output, and limitations are in the &lt;a href="https://dev.to/lab-runs/gpt-56-explicit-prompt-cache-controls-cost-proof-2026"&gt;public lab note&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Caching worked immediately in every GPT-5.6 configuration. The first call was a miss, as expected, and every call after it reused almost the whole handbook.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Tokens sent per call&lt;/th&gt;
&lt;th&gt;Tokens reused on repeat calls&lt;/th&gt;
&lt;th&gt;Share of the prompt reused&lt;/th&gt;
&lt;th&gt;Charged as a cache write on call 1&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6, automatic&lt;/td&gt;
&lt;td&gt;2,032&lt;/td&gt;
&lt;td&gt;2,029&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;td&gt;2,029&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.6, explicit marker&lt;/td&gt;
&lt;td&gt;2,032&lt;/td&gt;
&lt;td&gt;2,001&lt;/td&gt;
&lt;td&gt;98.5%&lt;/td&gt;
&lt;td&gt;2,001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5, 24h retention&lt;/td&gt;
&lt;td&gt;2,032&lt;/td&gt;
&lt;td&gt;1,792&lt;/td&gt;
&lt;td&gt;88.2%&lt;/td&gt;
&lt;td&gt;field not present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 24-hour cache is not available on GPT-5.6.&lt;/strong&gt; Configuration B2 came back with a flat rejection: &lt;code&gt;Invalid value: '24h'. Supported values are: '30m'.&lt;/code&gt; So a reuse window that used to last a full day now lasts half an hour. In business terms, a workload that fires once an hour used to ride the cache all day. On GPT-5.6 it never hits at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filling the cache is its own bill.&lt;/strong&gt; On the first call of each GPT-5.6 run, the response came back with a field our GPT-5.5 calls never returned: &lt;code&gt;cache_write_tokens&lt;/code&gt;. It read 2,029 in automatic mode and 2,001 in explicit mode. OpenAI's pricing page charges cache writes at $1.25 per million tokens against $1.00 for plain input. Plainly: the call that loads your instruction block into the cache costs 25% more than an ordinary call would have. We did not test whether GPT-5.5 bills the same thing without reporting it, so read this as "5.6 makes the write visible and prices it," not as proof the charge is brand new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The explicit marker buys certainty, not volume.&lt;/strong&gt; Marking the boundary yourself reused 2,001 tokens instead of 2,029. That is 28 tokens less, about 1.4% of the prompt, and at GPT-5.6 Luna's list price the gap is a rounding error. What you get in exchange is a cached section that is identical on every call because you defined its edge, instead of one the system redraws for you. For anything you have to forecast or explain to a finance team, that predictability is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your business
&lt;/h2&gt;

&lt;p&gt;Here is the arithmetic that decides whether the upgrade helps or hurts you. Using OpenAI's published prices for GPT-5.6 Luna ($1.00 per million tokens sent, $0.10 per million reused, $1.25 per million written to cache) and our measured 2,029-token block, per 1,000 calls that share that block:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Traffic pattern&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Cost per 1,000 calls&lt;/th&gt;
&lt;th&gt;Versus no caching&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Busy: several calls every half hour&lt;/td&gt;
&lt;td&gt;One write, the rest reused&lt;/td&gt;
&lt;td&gt;about $0.21&lt;/td&gt;
&lt;td&gt;roughly 90% cheaper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quiet: calls more than 30 minutes apart&lt;/td&gt;
&lt;td&gt;Every call misses and pays to write&lt;/td&gt;
&lt;td&gt;about $2.54&lt;/td&gt;
&lt;td&gt;roughly 25% &lt;em&gt;more expensive&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No caching engaged at all&lt;/td&gt;
&lt;td&gt;Plain input every time&lt;/td&gt;
&lt;td&gt;about $2.03&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are modelled numbers, not invoices. The quiet row assumes a miss still pays to write, which is what the first call of every run did in our test. We never let a cache actually expire and watched what happened next.&lt;/p&gt;

&lt;p&gt;The break-even is low. Reuse the same block twice inside a 30-minute window and you're already ahead. But the downside case is real, and it's the one nobody puts in a launch post: a low-traffic service on GPT-5.6 can pay a quarter more than before, for a feature sold as a discount.&lt;/p&gt;

&lt;p&gt;So the one thing to do differently after reading this: before you migrate, plot the gaps between your calls that share a prefix. If most gaps are under 30 minutes, turn the explicit marker on. If most are over, treat caching as a cost you have to justify, not a saving you get for free.&lt;/p&gt;

&lt;p&gt;Scale that up. A support assistant on steady daytime volume, sharing a 2,000-token policy block, saves close to 90% of its instruction cost. An internal report generator that runs eleven times a day across office hours saves nothing and pays the write premium on every run. Same feature, opposite outcome. The variable isn't your prompt, it's your traffic shape.&lt;/p&gt;

&lt;p&gt;There's a migration trap here too. Carry your old &lt;code&gt;prompt_cache_retention: "24h"&lt;/code&gt; setting from GPT-5.5 across to 5.6 and it won't do what it used to. Anyone who budgeted for a day-long cache needs to redo that number against half an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can this survive your workflow?
&lt;/h2&gt;

&lt;p&gt;Run your own traffic through the same question before you migrate. The pattern to check is simple: how often does the same unchanging prefix get sent, and are those sends closer together than half an hour?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer support and ticket triage&lt;/strong&gt;. Usually a clean win. Steady volume, one shared policy block, cache stays warm through the working day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order and billing automation&lt;/strong&gt;. Depends entirely on volume. A few hundred orders an hour caches beautifully. A few dozen a day pays the write premium on nearly every call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRM enrichment and batch back-office jobs&lt;/strong&gt;. Group them. Scattered across the day they lose; run as one batch and they win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overnight or weekly reporting&lt;/strong&gt;. The 30-minute window will not carry you between runs. Assume no cache benefit and check whether the write charge is quietly costing you extra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything with zero-data-retention&lt;/strong&gt;. Extended caching behaves differently for ZDR organizations, so verify against your own account settings rather than the default documentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want this measured against your real workload instead of a synthetic handbook, that is the kind of claim-bound run &lt;a href="https://dev.to/proof-studio"&gt;Effloow's Proof Studio&lt;/a&gt; exists to produce, and you can see the &lt;a href="https://dev.to/services"&gt;services&lt;/a&gt; side for scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use it, when to skip it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Switch the explicit marker on when:&lt;/strong&gt; you have a stable prefix over 1,024 tokens, your traffic keeps it warm, and you need the cached region to be the same on every call so your cost forecast holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip it when:&lt;/strong&gt; your prompts are short, your prefix changes per user, or your calls are spread thinner than one every 30 minutes. In the last case the honest move is to measure whether caching is costing you money before you optimize anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not assume it carried over when:&lt;/strong&gt; you are moving from GPT-5.5 or earlier. The setting name changed, the maximum lifetime shrank, and a new charge appeared. Three separate things to re-check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations of this test
&lt;/h2&gt;

&lt;p&gt;One prefix, one account, one region, four calls per configuration, all on a single day. That's enough to show whether the cache engages, what the API accepts and rejects, and which new fields appear. It isn't enough to characterise eviction, routing across machines, or hit rates under production concurrency.&lt;/p&gt;

&lt;p&gt;We tested &lt;code&gt;gpt-5.6-luna&lt;/code&gt; only. Sol and Terra were never exercised. The pricing structure looks the same shape across all three, but we did not verify their cache behaviour.&lt;/p&gt;

&lt;p&gt;Our repeat calls sat about two seconds apart, so nothing here tests the 30-minute boundary directly. The rejection message is our evidence that 30 minutes is the ceiling. We never watched a cache expire.&lt;/p&gt;

&lt;p&gt;Finally, every dollar figure above is arithmetic over OpenAI's published price table and our measured token counts. No billing statement was read. No dollar amount was directly measured. Treat the percentages as the shape of the economics, then confirm against your own invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Effloow added
&lt;/h2&gt;

&lt;p&gt;The changelog says "explicit prompt caching controls" and stops there. What is not in any vendor document is a side-by-side measurement of how much of the same prompt each mode actually reuses, the rejection that proves the 24-hour window is gone on GPT-5.6, and the break-even table showing the traffic pattern where the feature turns into a 25% surcharge. Those three came from our own run and are reproducible from the &lt;a href="https://dev.to/lab-runs/gpt-56-explicit-prompt-cache-controls-cost-proof-2026"&gt;public lab note&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For related measured work, see our earlier run on the &lt;a href="https://dev.to/articles/openai-prompt-cache-retention-24h-cost-proof-2026"&gt;24-hour prompt cache on GPT-5.5&lt;/a&gt; and the broader &lt;a href="https://dev.to/articles/token-optimization-production-llm-cost-guide-2026"&gt;token optimization guide for production LLM systems&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Q: Can I still get a 24-hour prompt cache on GPT-5.6?
&lt;/h3&gt;

&lt;p&gt;We did not test &lt;code&gt;prompt_cache_retention&lt;/code&gt; against GPT-5.6, so we can't tell you whether the old parameter is still accepted there. We can tell you the thing that actually matters for your bill: on GPT-5.6, &lt;code&gt;prompt_cache_options.ttl&lt;/code&gt; took &lt;code&gt;30m&lt;/code&gt; and rejected &lt;code&gt;24h&lt;/code&gt; with an HTTP 400. Whatever the old parameter does on 5.6, a day-long cache is not on the menu.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: Why did the explicit mode cache fewer tokens than automatic mode?
&lt;/h3&gt;

&lt;p&gt;Because we told it where to stop. The marker sat at the end of the handbook block, so the cached region ended there. Automatic mode extended a little further into the request. The difference was 28 tokens, 1.4% of the prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: Is caching ever worse than not caching?
&lt;/h3&gt;

&lt;p&gt;By the price table, yes. A cache write bills at 1.25 times the normal input rate, so if nearly every call misses, you pay a 25% premium for a feature that never pays back. Low-traffic services should check this before enabling anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q: What is the minimum prompt size for caching to engage?
&lt;/h3&gt;

&lt;p&gt;OpenAI documents 1,024 tokens. Below that, the reused-token count comes back as zero. Our prefix was 2,032 tokens, which is why every configuration engaged the cache on the second call.&lt;/p&gt;

&lt;h2&gt;
  
  
  For your engineers
&lt;/h2&gt;

&lt;p&gt;Exact configuration from the run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Endpoint: &lt;code&gt;POST https://api.openai.com/v1/responses&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Models: &lt;code&gt;gpt-5.6-luna&lt;/code&gt;, &lt;code&gt;gpt-5.5-2026-04-23&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Prefix: 9,208 characters of synthetic text, SHA-256 &lt;code&gt;80761fcc454ee750...&lt;/code&gt;, measured at 2,032 input tokens&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_output_tokens: 48&lt;/code&gt;, 4 calls per configuration, ~2s apart&lt;/li&gt;
&lt;li&gt;Cache fields read from &lt;code&gt;usage.input_tokens_details&lt;/code&gt;: &lt;code&gt;cached_tokens&lt;/code&gt;, &lt;code&gt;cache_write_tokens&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/gpt56-explicit-cache-probe.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Explicit-mode request shape (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gpt-5.6-luna"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt_cache_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"effloow-cache-probe-explicit-v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt_cache_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"explicit"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"developer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;prefix&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                     &lt;/span&gt;&lt;span class="nl"&gt;"prompt_cache_breakpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"explicit"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;question&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rejected TTL call, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invalid value: '24h'. Supported values are: '30m'."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_request_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"param"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prompt_cache_options.ttl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_value"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;prompt_cache_key&lt;/code&gt; matters more on GPT-5.6 than it did before. OpenAI's caching guide describes it as required for reliable matching on 5.6 and later, and recommends keeping traffic on a single key to roughly 15 requests per minute. We used a distinct key per configuration so the three runs could not contaminate each other.&lt;/p&gt;

&lt;p&gt;Evidence: &lt;a href="https://dev.to/lab-runs/gpt-56-explicit-prompt-cache-controls-cost-proof-2026"&gt;public lab note&lt;/a&gt;, with the machine-readable artifact saved alongside it in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI API changelog, July 9, 2026 (GPT-5.6, explicit prompt caching controls) and May 29, 2026 (&lt;code&gt;prompt_cache_retention&lt;/code&gt; 24h default): &lt;a href="https://developers.openai.com/api/docs/changelog" rel="noopener noreferrer"&gt;https://developers.openai.com/api/docs/changelog&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAI prompt caching guide (1,024-token minimum, &lt;code&gt;cached_tokens&lt;/code&gt;, &lt;code&gt;prompt_cache_key&lt;/code&gt;, &lt;code&gt;prompt_cache_options&lt;/code&gt;, breakpoints): &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching" rel="noopener noreferrer"&gt;https://developers.openai.com/api/docs/guides/prompt-caching&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAI API pricing (GPT-5.6 Luna/Terra/Sol input, cached input, cache write, output rates): &lt;a href="https://developers.openai.com/api/docs/pricing" rel="noopener noreferrer"&gt;https://developers.openai.com/api/docs/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Effloow Lab run, 2026-07-20: &lt;a href="https://dev.to/lab-runs/gpt-56-explicit-prompt-cache-controls-cost-proof-2026"&gt;public lab note&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>openai</category>
      <category>promptcaching</category>
      <category>gpt56</category>
      <category>apicost</category>
    </item>
    <item>
      <title>WCAG 2.2 Minimum Target Size: the AA Failure Hiding Behind a Green 92</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:43:38 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/wcag-22-minimum-target-size-the-aa-failure-hiding-behind-a-green-92-omh</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/wcag-22-minimum-target-size-the-aa-failure-hiding-behind-a-green-92-omh</guid>
      <description>&lt;p&gt;The automated accessibility score read 92. A near-green number at the top of the report. Yet lower on the same page, the pagination links measure 22px on a side. Tap "3" with your thumb and "2" or "4" goes along with it. The score looks like a pass while your fingertip keeps sliding off.&lt;/p&gt;

&lt;p&gt;That gap is where this post starts. I planted one of WCAG 2.2's new success criteria, &lt;strong&gt;SC 2.5.8 Target Size (Minimum)&lt;/strong&gt;, in a sandbox and measured it twice: once with a script I wrote, once with Lighthouse. Here's the conclusion up front. Automated tools now catch obvious size violations just fine. But the real difficulty of this criterion isn't size, it's the &lt;strong&gt;exceptions&lt;/strong&gt;, and judging those is still a human's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 24×24 comes from
&lt;/h2&gt;

&lt;p&gt;Foundations first. WCAG (Web Content Accessibility Guidelines) is the web accessibility standard produced by the W3C's WAI. Version 2.2 became a formal W3C Recommendation on October 5, 2023; the current edition is dated December 12, 2024, and it was approved as ISO/IEC 40500:2025 on October 21, 2025. It adds nine success criteria over 2.1 and retires one old one, 4.1.1 Parsing.&lt;/p&gt;

&lt;p&gt;Of those nine, the one that makes you touch CSS today is &lt;strong&gt;SC 2.5.8 Target Size (Minimum)&lt;/strong&gt;, Level AA. The wording is short. Straight from the W3C text: "The size of the target for pointer inputs is at least 24 by 24 CSS pixels, except when…". A pointer target must be at least 24×24 CSS pixels. This is for people who can't aim precisely at a small control: thick fingers, a tremor, a moving bus.&lt;/p&gt;

&lt;p&gt;Don't confuse the numbers. 24×24 is the AA &lt;strong&gt;floor&lt;/strong&gt;. Above it sits SC 2.5.5 Target Size (Enhanced), Level AAA, which asks for 44×44. For reference, Apple's Human Interface Guidelines recommend 44pt and Android's Material recommends 48dp. Those two are platform recommendations, not W3C standards, so treat them as reference values only. In practice I build new components at 44 or more from the start and use 24 as the pass line when auditing legacy code.&lt;/p&gt;

&lt;p&gt;The "CSS pixels" qualifier earns its keep here. The rule is measured in CSS pixels, not physical ones, so on a high-density screen with a device pixel ratio of 3, &lt;code&gt;min-height: 24px&lt;/code&gt; still satisfies it. Get the viewport meta tag wrong and let zoom behave oddly, though, and the math drifts. That's why I measure post-render with &lt;code&gt;getBoundingClientRect()&lt;/code&gt; rather than trusting the stylesheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Planting the bugs, measuring by hand
&lt;/h2&gt;

&lt;p&gt;Words alone don't land it. So I built a static page with violations deliberately seeded in. Four sections in a throwaway sandbox:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;toolbar-bad: four 16×16 icon buttons (Bold / Italic / Underline / Link)&lt;/li&gt;
&lt;li&gt;pager-bad: five pagination links authored at 20×20 that render as 22×22 once a 1px border is added, zero spacing&lt;/li&gt;
&lt;li&gt;toolbar-good: the same buttons grown to &lt;code&gt;min-width/min-height: 24px&lt;/code&gt; plus padding&lt;/li&gt;
&lt;li&gt;pager-good: 24×24 with &lt;code&gt;margin: 2px&lt;/code&gt; for spacing too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core markup:&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;!-- violation: 16x16 --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"toolbar-bad"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Bold"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;B&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Italic"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;I&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- compliant: 24x24 minimum --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"toolbar-good"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Bold"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;B&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Italic"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;I&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;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 css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.toolbar-bad&lt;/span&gt; &lt;span class="nt"&gt;button&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&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;16px&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;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.toolbar-good&lt;/span&gt; &lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&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;min-height&lt;/span&gt;&lt;span class="p"&gt;:&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;padding&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before handing anything to axe or Lighthouse, I wrote a script that computes the criterion directly. That gives me a control group for what the automated tools do and don't see. The auditor does two things. First, it measures the rendered size of every interactive target and flags anything under 24. Second, it applies the &lt;strong&gt;Spacing exception&lt;/strong&gt; to what it flags: it centers a 24px-diameter circle on each target's bounding box and checks, by Euclidean distance, whether any two circles intersect.&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;// WCAG 2.2 SC 2.5.8 minimum target size auditor&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;MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 24px diameter -&amp;gt; radius 12&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href],button,input,select,textarea,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
              &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[role="button"],[role="link"],[tabindex]:not([tabindex="-1"])&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;els&lt;/span&gt; &lt;span class="o"&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;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetParent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;els&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;el&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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="na"&gt;w&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;h&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
             &lt;span class="na"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;boxes&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// 24+ passes&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tooClose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;                  &lt;span class="c1"&gt;// spacing-exception check&lt;/span&gt;
      &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&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="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tooClose&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FAIL&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;PASS(spacing)&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="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&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="na"&gt;undersized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;findings&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="nx"&gt;findings&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;Here's what it returned in Chrome:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"undersized"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bold"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16x16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Italic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16x16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Underline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"16x16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Link"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16x16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22x22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22x22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22x22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22x22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"22x22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAIL"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine of 18 targets fell under 24, all FAIL. The 24×24 targets in the good sections never got flagged; they dropped out at the measurement stage. The fun part is pager-bad coming back as 22×22. I wrote 20px in the CSS, but a 1px border on each side pushed the rendered size to 22. Reading the code by eye would miss that error. Measuring post-render exposes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How far did the automated tools see?
&lt;/h2&gt;

&lt;p&gt;Now the control group. I ran the same page through a Lighthouse mobile snapshot. Accessibility score: &lt;strong&gt;92&lt;/strong&gt;. Two audits failed: &lt;code&gt;target-size&lt;/code&gt; and &lt;code&gt;landmark-one-main&lt;/code&gt;. The &lt;code&gt;target-size&lt;/code&gt; audit, powered by axe-core, scored 0 and pointed at the exact same nine nodes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Accessibility: 92
Failed audits: target-size, landmark-one-main
target-size: score=0, flagged 9 nodes
  &amp;lt;button aria-label="Bold"&amp;gt; ... &amp;lt;a href="#5"&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth pinning down.&lt;/p&gt;

&lt;p&gt;The good news first. The trope that "automated tools only see the surface of accessibility" is out of date for this criterion. axe-core now ships a &lt;code&gt;target-size&lt;/code&gt; rule, and it named the &lt;strong&gt;exact same nine&lt;/strong&gt; my auditor did. That my verdict, spacing math included, matched axe's tells me the tool actually implements the 24px-circle-overlap logic. On size violations alone, axe is reliable. This is a counter-case to my earlier experiment on &lt;a href="https://dev.to/en/blog/en/axe-automated-a11y-coverage-gap-2026"&gt;the four things axe structurally misses&lt;/a&gt;. Back then, the checks that need human judgment stayed hidden behind the green; target size, being rule-shaped, is one the automation caught up to.&lt;/p&gt;

&lt;p&gt;The bad news. The score was still 92. A page with one AA success criterion plainly broken gets a 92. The score is a weighted average, so one rule at zero still looks like a pass when the rest carry it. &lt;strong&gt;A score is not conformance.&lt;/strong&gt; WCAG is a binary pass/fail, not a continuous value like 92. To claim AA you have to meet every AA criterion, 2.5.8 included, with no exceptions. That's why you don't hand a dashboard's green number over as evidence of conformance. This trap outlives even the &lt;a href="https://dev.to/en/blog/en/a11y-lighthouse-audit-fix-2026"&gt;basic catch-and-fix flow with Lighthouse&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exceptions are the real exam
&lt;/h2&gt;

&lt;p&gt;Here's the heart of the criterion. 2.5.8 requires 24×24 but carves out five exceptions. In the W3C's order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exception&lt;/th&gt;
&lt;th&gt;Gist&lt;/th&gt;
&lt;th&gt;Tool-decidable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spacing&lt;/td&gt;
&lt;td&gt;Small is fine if the 24px circles don't intersect&lt;/td&gt;
&lt;td&gt;Partly (geometry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equivalent&lt;/td&gt;
&lt;td&gt;The same function exists via another control that meets the size&lt;/td&gt;
&lt;td&gt;No (human)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;The target sits in a sentence or is bound by line-height&lt;/td&gt;
&lt;td&gt;Partly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User agent control&lt;/td&gt;
&lt;td&gt;The browser, not the author, sets the size&lt;/td&gt;
&lt;td&gt;Partly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Essential&lt;/td&gt;
&lt;td&gt;The presentation is essential or legally required&lt;/td&gt;
&lt;td&gt;No (human)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Spacing exception is the one you'll reach for most. The original: "Undersized targets … are positioned so that if a 24 CSS pixel diameter circle is centered on the bounding box of each, the circles do not intersect another target or the circle for another undersized target." Center a 24px-diameter circle on each target; if those circles don't overlap, an under-24 target still passes. For two circles not to overlap, their centers must be at least 24px apart.&lt;/p&gt;

&lt;p&gt;That explains why my pager-bad fails. The 22×22 links sit with no gap, so adjacent centers are 22px apart, under 24, and the circles overlap. But leave the links at 22×22 and add &lt;code&gt;margin&lt;/code&gt; to push the centers 24px or more apart, and they pass through the Spacing exception without growing at all. That's the escape hatch for dense toolbars or data-heavy tables where you physically can't enlarge the icons.&lt;/p&gt;

&lt;p&gt;The rest of the exceptions are the problem. Equivalent and Essential can't be decided by automation in principle. "Is there a larger button elsewhere on the page for the same function as this tiny delete icon?" requires understanding what the page means. So even when axe marks a target FAIL, whether that's a real violation or a covered exception is yours to confirm. Put honestly: &lt;strong&gt;an automated FAIL is a signal to review for exceptions, not a final verdict on its own.&lt;/strong&gt; And an automated PASS is no guarantee of conformance either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it in code
&lt;/h2&gt;

&lt;p&gt;The prescription splits by cause.&lt;/p&gt;

&lt;p&gt;The common case: a target that simply didn't hit the size. Set a floor. Reach for &lt;code&gt;min-width&lt;/code&gt;/&lt;code&gt;min-height&lt;/code&gt;, not &lt;code&gt;width&lt;/code&gt;; you want the target to grow with its content but never drop below 24.&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;/* icon button: keep the visual size, grow only the hit area to 24 */&lt;/span&gt;
&lt;span class="nc"&gt;.icon-btn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&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;min-height&lt;/span&gt;&lt;span class="p"&gt;:&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;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need to keep a 16px icon visually but widen the fingertip area, extend the hit target with transparent padding or a pseudo-element.&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;.tiny-icon&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="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.tiny-icon&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c"&gt;/* invisible 24x24 hit area */&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&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="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&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;24px&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;translate&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="m"&gt;-50%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a dense UI where you truly can't enlarge anything, route it through the Spacing exception. Secure a 24px-or-more center distance with &lt;code&gt;gap&lt;/code&gt; or &lt;code&gt;margin&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="c"&gt;/* keep 22x22 but push center distance to &amp;gt;=24px -&amp;gt; passes via Spacing */&lt;/span&gt;
&lt;span class="nc"&gt;.pager&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22px&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;22px&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="m"&gt;2px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c"&gt;/* 22 + 2 + 2 = 26px between centers */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then keep that auditor script on hand as a bookmarklet or a CI step. Even when axe looks like enough, the habit of reading the post-render numbers yourself catches errors like that 1px border. I use it as the last box on my pre-deploy manual check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls that keep catching people
&lt;/h2&gt;

&lt;p&gt;Run the audit a few times and you trip in the same spots. Four to note up front.&lt;/p&gt;

&lt;p&gt;First, mixing up &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;min-width&lt;/code&gt;. &lt;code&gt;width: 24px&lt;/code&gt; clips overflowing content or pins the target to exactly 24. You want a floor, not a fixed size. Always use &lt;code&gt;min-width&lt;/code&gt;/&lt;code&gt;min-height&lt;/code&gt;. That single distinction stops a button whose text grew in a responsive layout from quietly turning into a violation.&lt;/p&gt;

&lt;p&gt;Second, overlapping hit areas. When you extend a 24×24 hit area with &lt;code&gt;::after&lt;/code&gt;, if two adjacent icons' extended areas overlap, the wrong target fires. The visual is small, but the actual click region has widened. Extend, but only up to the line where you don't overlap a neighbor. The 24px-circle-overlap math earns its keep here too.&lt;/p&gt;

&lt;p&gt;Third, targets shrunk with &lt;code&gt;transform: scale()&lt;/code&gt;. CSS &lt;code&gt;transform&lt;/code&gt; shrinks the paint, not the layout size. So &lt;code&gt;getBoundingClientRect()&lt;/code&gt; returns the on-screen size after the scale, which is easy to get wrong against your intent. If you scaled an icon down, remember to re-measure at the post-scale size.&lt;/p&gt;

&lt;p&gt;Fourth, trusting the focus ring and relaxing. A clearly visible keyboard focus doesn't mean the pointer target is big enough. 2.5.8 is about pointer inputs like mouse and touch, a separate axis from keyboard accessibility (2.1.1) or focus visibility (2.4.11). Accessibility doesn't roll one axis into the next; clearing one doesn't clear another. As with &lt;a href="https://dev.to/en/blog/en/accessible-name-agents-2026"&gt;an accessible name that quietly goes empty&lt;/a&gt;, size, name, and focus each need their own check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up: what a developer does about 24px
&lt;/h2&gt;

&lt;p&gt;Compressed: WCAG 2.2 SC 2.5.8 nails the minimum pointer-target size to 24×24 CSS pixels at Level AA, automated tools catch the size violations well but leave the exception calls to you, and a score like 92 is not evidence of conformance.&lt;/p&gt;

&lt;p&gt;As a pre-deploy checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Measure the rendered size of every button, link, and input. Read &lt;code&gt;getBoundingClientRect()&lt;/code&gt;, not the CSS value.&lt;/li&gt;
&lt;li&gt;When something comes back under 24, prescribe in three ways. First, grow it with &lt;code&gt;min-width/min-height: 24px&lt;/code&gt;. Second, if the visual size must stay, extend a transparent hit area. Third, when you truly can't enlarge, engineer a 24px-plus center distance for the Spacing exception.&lt;/li&gt;
&lt;li&gt;For anything axe marks FAIL, confirm by hand whether it falls under the Equivalent or Essential exception.&lt;/li&gt;
&lt;li&gt;Don't submit a dashboard score as a conformance report. AA is a binary pass.&lt;/li&gt;
&lt;li&gt;Build new components at 44 or more from the start and you clear AA and AAA in one move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a small number, but a UI your fingertip keeps missing is a hard UI to use no matter how green the score. 24px is the least you owe that fingertip.&lt;/p&gt;

&lt;p&gt;If you want structured data emitted reliably server-side, or a code-level review of an existing site's accessibility, target sizes, or GEO readiness, I take on consulting and implementation work personally. The contact path in my profile is the easiest way to reach me.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The meta Line That Decides If AI Overviews Can Quote You</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Sat, 18 Jul 2026 06:39:43 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/the-meta-line-that-decides-if-ai-overviews-can-quote-you-13d1</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/the-meta-line-that-decides-if-ai-overviews-can-quote-you-13d1</guid>
      <description>&lt;p&gt;I used to think a robots meta line only turned off the little summary under a search result. Now that same line decides whether Google's AI Overview is allowed to quote your page at all. Google Search Central rewrote its docs to say so explicitly. And yet plenty of sites still carry a &lt;code&gt;nosnippet&lt;/code&gt; someone pasted into a layout template years ago — quietly locking AI search out of their entire content, without anyone noticing.&lt;/p&gt;

&lt;p&gt;Today I didn't just read the directives. I built one page that's deliberately broken and one that's fixed, then wrote a small audit script that parses the HTML and decides what an AI system could actually quote from each. Every log below is real output from that sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snippets, AI Overviews, and what robots meta actually governs
&lt;/h2&gt;

&lt;p&gt;Terms first. A &lt;strong&gt;snippet&lt;/strong&gt; is the summary line under a title in search results. It used to be nothing more than a click-bait preview. &lt;strong&gt;AI Overviews&lt;/strong&gt; and &lt;strong&gt;AI Mode&lt;/strong&gt; are Google's generative answers pinned to the top of search (or in a conversational view). They summarize several pages into prose and cite the source pages as evidence. Here's the pivotal shift: Google decided that whether a page is used as &lt;strong&gt;input&lt;/strong&gt; to those generative answers is governed by the same switches as the old snippet directives.&lt;/p&gt;

&lt;p&gt;That switch is a snippet directive inside &lt;code&gt;&amp;lt;meta name="robots"&amp;gt;&lt;/code&gt;. This is where a common confusion starts. &lt;code&gt;robots.txt&lt;/code&gt; and the robots meta tag are entirely different levers. &lt;a href="https://dev.to/en/blog/en/ai-crawler-control-robots-txt-llms-txt-2026"&gt;Controlling AI crawler access itself with robots.txt and llms.txt&lt;/a&gt; decides &lt;em&gt;whether they get in&lt;/em&gt;; the robots meta tag decides &lt;em&gt;what may be indexed and displayed once they're in&lt;/em&gt;. Order matters because of this. If you block crawling in robots.txt, Google never reads the page's meta tags in the first place. For a snippet directive to take effect, the page has to be crawlable and indexable. Confuse "block access" with "tune display" and you get the exact opposite of what you intended.&lt;/p&gt;

&lt;p&gt;Why now? Because a meaningful chunk of search traffic is drifting from "a list of links" toward "a summarized answer." Being cited as evidence in an AI answer has itself become a distribution channel. And the doorway to that channel hangs on a very old line of markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The official rules — four directives, and the exact values
&lt;/h2&gt;

&lt;p&gt;Straight from the Google Search Central robots meta docs. Not a guess — what the page actually says.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;nosnippet&lt;/code&gt;. The definition: "Do not show a text snippet or video preview in the search results for this page." Then the decisive sentence follows. This applies to "all forms of search results (web search, Google Images, Discover, AI Overviews, AI Mode) and will also &lt;strong&gt;prevent the content from being used as a direct input for AI Overviews and AI Mode.&lt;/strong&gt;" So &lt;code&gt;nosnippet&lt;/code&gt; no longer means "hide the summary." It means "drop me from the citation pool."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max-snippet:[number]&lt;/code&gt;. Sets the maximum character count for a text snippet. &lt;code&gt;0&lt;/code&gt; means no snippet (effectively the same as &lt;code&gt;nosnippet&lt;/code&gt;), &lt;code&gt;-1&lt;/code&gt; lets Google choose the length, a positive number caps it. The docs say the same thing about AI: it "will also limit how much of the content may be used as a direct input for AI Overviews and AI Mode." In other words, &lt;code&gt;max-snippet:0&lt;/code&gt; blocks citation, &lt;code&gt;max-snippet:-1&lt;/code&gt; allows the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;max-image-preview:[none|standard|large]&lt;/code&gt;. The maximum size of an image preview in results. You have to open it to &lt;code&gt;large&lt;/code&gt; for a large preview to be eligible. The default is usually &lt;code&gt;standard&lt;/code&gt;, so if you want your hero image shown big and never touch this, you're stuck with a thumbnail.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;data-nosnippet&lt;/code&gt;. This one is an HTML attribute on an element, not a meta tag. Use it when you want to exclude &lt;strong&gt;just one block&lt;/strong&gt; from the snippet rather than the whole page. Two traps here. First, the docs are explicit that this attribute only works on &lt;code&gt;span&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;, and &lt;code&gt;section&lt;/code&gt;. A &lt;code&gt;&amp;lt;p data-nosnippet&amp;gt;&lt;/code&gt; is simply ignored. Second, there's a warning not to "add or remove the data-nosnippet attribute of existing nodes through JavaScript." The reason is simple: &lt;a href="https://dev.to/en/blog/en/ai-crawlers-dont-render-javascript-csr-2026"&gt;many AI crawlers don't render JavaScript&lt;/a&gt;, so an attribute you attach at runtime doesn't exist as far as the crawler is concerned. It has to be baked into the initial HTML your server returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  When they conflict, the most restrictive one wins
&lt;/h2&gt;

&lt;p&gt;This is where real sites break. A single page can carry several directives: a generic &lt;code&gt;robots&lt;/code&gt; tag, a &lt;code&gt;googlebot&lt;/code&gt;-specific tag, plus whatever a CMS plugin injected. Which wins? The docs are blunt: "In the case of conflicting robots rules, the more restrictive rule applies. For example, if a page has both &lt;code&gt;max-snippet:50&lt;/code&gt; and &lt;code&gt;nosnippet&lt;/code&gt; rules, the &lt;code&gt;nosnippet&lt;/code&gt; rule will apply."&lt;/p&gt;

&lt;p&gt;The scary part is the direction. Rules never merge toward looser; they only merge toward tighter. You can put &lt;code&gt;max-snippet:160&lt;/code&gt; in the &lt;code&gt;googlebot&lt;/code&gt; tag to "give Google a generous snippet," but if a &lt;code&gt;nosnippet&lt;/code&gt; still sits in the generic &lt;code&gt;robots&lt;/code&gt; tag, the result is a snippet of zero. You thought the door was open; it was locked. That's exactly why eyeballing two tags and concluding "looks fine" is dangerous.&lt;/p&gt;

&lt;p&gt;So I decided to audit it with a parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  I built two pages and audited them with a parser
&lt;/h2&gt;

&lt;p&gt;In a throwaway sandbox outside the repo, I created two static HTML files. One, &lt;code&gt;broken.html&lt;/code&gt;, packs in the mistakes you actually see in the wild. The other, &lt;code&gt;fixed.html&lt;/code&gt;, does what was intended.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and body of &lt;code&gt;broken.html&lt;/code&gt; contain these:&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;!-- mistake 1: nosnippet on the generic robots tag — the classic template-wide paste --&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;"robots"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"index,follow,nosnippet"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- mistake 2: googlebot tries to open a snippet, but the nosnippet above is "most restrictive" and wins --&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;"googlebot"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"max-snippet:160"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
...
&lt;span class="c"&gt;&amp;lt;!-- mistake 3: data-nosnippet on a p → unsupported element, ignored --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;data-nosnippet&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Internal note: I want this out of the snippet, but p doesn't work.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fixed.html&lt;/code&gt; opens the page up and isolates the single block it wants excluded onto a supported element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- page level: allow full snippet + large image preview --&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;"robots"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"index,follow,max-snippet:-1,max-image-preview:large"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
...
&lt;span class="c"&gt;&amp;lt;!-- element level: internal note only, on a supported span --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;data-nosnippet&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Internal note: excluded from the snippet.&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I wrote &lt;code&gt;audit.mjs&lt;/code&gt;: it parses the HTML with &lt;code&gt;node-html-parser&lt;/code&gt;, reads both the generic &lt;code&gt;robots&lt;/code&gt; and the &lt;code&gt;googlebot&lt;/code&gt; directives, merges them under "most restrictive wins," and checks the tag name of every &lt;code&gt;data-nosnippet&lt;/code&gt; element. The merge core looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// if any nosnippet or max-snippet:0 is present, everything is blocked&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;effectiveSnippetPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dirsList&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;hardZero&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// -1 = Google chooses the length&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;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;dirsList&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nosnippet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;hardZero&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSnippet&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;hardZero&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSnippet&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSnippet&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxSnippet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hardZero&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="na"&gt;chars&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="na"&gt;aiInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;aiInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`capped@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cap&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the actual output for both files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;========================================================
FILE: broken.html
  effective text snippet : 0 chars
  AI Overview text input : blocked
  image preview          : standard(default)
  data-nosnippet elements: 1
  [ERROR] PAGE_SNIPPET_BLOCKED
  [WARN]  CONFLICT_MOST_RESTRICTIVE: robots=nosnippet vs googlebot=max-snippet:160 → the more restrictive nosnippet wins (official).
  [INFO]  IMAGE_PREVIEW_LIMITED
  [ERROR] DATA_NOSNIPPET_BAD_ELEMENT: &amp;lt;p data-nosnippet&amp;gt; ignored. Only span/div/section (official).
========================================================
FILE: fixed.html
  effective text snippet : full (Google chooses)
  AI Overview text input : full
  image preview          : large
  data-nosnippet elements: 1
  findings               : none — clean
========================================================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..%2F..%2F..%2Fassets%2Fblog%2Frobots-snippet-controls-ai-overviews-2026%2Faudit-report.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/..%2F..%2F..%2Fassets%2Fblog%2Frobots-snippet-controls-ai-overviews-2026%2Faudit-report.png" alt="Audit of robots snippet directives — broken.html blocks AI input, fixed.html allows full input" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The numbers make it plain. &lt;code&gt;broken.html&lt;/code&gt;, no matter how good its content, is dropped wholesale from AI Overview input. The developer who put &lt;code&gt;max-snippet:160&lt;/code&gt; in the &lt;code&gt;googlebot&lt;/code&gt; tag and believed "the snippet is open" was standing at a locked door. &lt;code&gt;fixed.html&lt;/code&gt; is fully quotable, has a large image preview open, and excludes exactly one line — the internal note. The four problems the audit flagged (page-wide block, wrong element, conflict, image limit) are all patterns that recur on live sites.&lt;/p&gt;

&lt;p&gt;One rule for the audit: run it against the &lt;strong&gt;HTML your server actually returns&lt;/strong&gt;. The Elements panel in DevTools shows the DOM after JavaScript runs, so any meta tag manipulated at runtime will differ from what the crawler sees. Pull the raw response instead — &lt;code&gt;curl -s &amp;lt;URL&amp;gt; | grep -i 'name="robots"'&lt;/code&gt;. That's the trap I once hit: DevTools showed a clean &lt;code&gt;max-snippet:-1&lt;/code&gt;, while the raw server response still carried a &lt;code&gt;nosnippet&lt;/code&gt; the CMS had injected. The truth is in the first bytes, not the rendered screen.&lt;/p&gt;

&lt;p&gt;Don't judge this by eye. Just as I &lt;a href="https://dev.to/en/blog/en/validate-structured-data-ci-jsonld-2026"&gt;validated JSON-LD structured data in CI&lt;/a&gt;, snippet directives deserve an automated parser check in the build pipeline. A human misses the moment one tag lands wrong; a parser doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest limit — eligibility, not a guarantee
&lt;/h2&gt;

&lt;p&gt;Time to lower expectations. Opening &lt;code&gt;max-snippet:-1&lt;/code&gt; and &lt;code&gt;max-image-preview:large&lt;/code&gt; does not make AI Overviews quote your page. These directives only open the &lt;strong&gt;eligibility&lt;/strong&gt; to be quoted; whether you actually are is Google's call. They don't raise your ranking either. Google has never said snippet directives are a ranking signal. There's no promise that removing &lt;code&gt;nosnippet&lt;/code&gt; brings more visitors.&lt;/p&gt;

&lt;p&gt;Look at the trade-off in the other direction honestly, too. &lt;code&gt;nosnippet&lt;/code&gt; isn't always a mistake. For the body of paid content, information that should only appear behind a login, or a page whose click incentive evaporates if it's shown whole in results, tightening the snippet is reasonable. What's new is that this choice now carries a cost: you're giving up the chance to be cited in an AI answer. You used to hide only the snippet; now you're also hiding your presence in generative search.&lt;/p&gt;

&lt;p&gt;My position: for public content — especially the docs, guides, and product explanations people arrive at looking for an answer — there's rarely a reason to tighten the snippet. If you must, exclude the offending block with &lt;code&gt;data-nosnippet&lt;/code&gt; rather than the whole page. A page-wide &lt;code&gt;nosnippet&lt;/code&gt; usually lingers in a state where nobody remembers why it was added, quietly eroding your citation chances.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do today
&lt;/h2&gt;

&lt;p&gt;The checklist for today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open your layout template's global robots meta first.&lt;/strong&gt; If a shared header carries &lt;code&gt;nosnippet&lt;/code&gt; or &lt;code&gt;max-snippet:0&lt;/code&gt;, your entire site is already out of the AI citation pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default for content you want quoted:&lt;/strong&gt; &lt;code&gt;max-snippet:-1, max-image-preview:large&lt;/code&gt;. That's the explicit signal that says "fine to use me as evidence in an AI answer."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude blocks, not pages.&lt;/strong&gt; Isolate internal notes, boilerplate, and paid-content teasers with &lt;code&gt;data-nosnippet&lt;/code&gt; on a &lt;code&gt;span&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;, or &lt;code&gt;section&lt;/code&gt;. It won't work on a &lt;code&gt;p&lt;/code&gt; or anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't toggle &lt;code&gt;data-nosnippet&lt;/code&gt; with JavaScript.&lt;/strong&gt; Bake it into the initial server HTML. In front of a crawler that doesn't render, a runtime attribute doesn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch conflicts with a parser.&lt;/strong&gt; Read both the generic &lt;code&gt;robots&lt;/code&gt; and &lt;code&gt;googlebot&lt;/code&gt; tags, merge under "most restrictive wins," and verify the effective policy with a CI script, not your eyes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need structured data emitted reliably server-side, or want an existing site audited from the snippet-and-crawler angle to see how it's exposed to AI search, I take on consulting and implementation work personally — reach me through the contact link on my profile. An old line of meta blocking an entire traffic path is more common than you'd think.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>geo</category>
      <category>aioverview</category>
      <category>structureddata</category>
    </item>
    <item>
      <title>content-visibility Measured: One CSS Line, 15x Less Forced Layout</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:39:01 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/content-visibility-measured-one-css-line-15x-less-forced-layout-3c03</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/content-visibility-measured-one-css-line-15x-less-forced-layout-3c03</guid>
      <description>&lt;p&gt;Same HTML. Same bytes. One extra line in the stylesheet. Forced layout cost fell from 27.3ms to 1.8ms, and initial LCP went from 464ms to 106ms. I didn't touch JavaScript, image optimization, or server config. I just told the browser not to compute what isn't on screen right now.&lt;/p&gt;

&lt;p&gt;That one line is &lt;code&gt;content-visibility: auto&lt;/code&gt;. I built two copies of a deliberately heavy 400-section page and measured, with a Chrome DevTools trace and the Performance API, exactly what this property saves and how much. Every number below is a real reading from that sandbox. And at the end I'll show the one spot where this optimization can quietly break accessibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the browser pays for every frame
&lt;/h2&gt;

&lt;p&gt;Start with the foundation. Every time the browser puts a page on screen, it runs a fixed pipeline: compute styles from the DOM and CSS, work out where each element sits and how big it is (layout), paint the pixels, then composite the layers. The catch is that this work happens for the whole page. A single table cell 3,000 pixels below the fold gets the same style and layout computation as the hero you're actually looking at.&lt;/p&gt;

&lt;p&gt;For a short blog post, none of this matters. For a long document, an infinite feed, a dashboard with hundreds of cards, or a huge product list, it does. The user sees one screenful, but the browser recomputes layout for tens of thousands of nodes it isn't showing — on every scroll, every resize, every font swap. That's the main reason heavy pages stutter when you scroll them.&lt;/p&gt;

&lt;p&gt;So the obvious question: can't the browser just skip the parts nobody can see? The long-standing answer was JavaScript virtualization — render only the rows in view, drop the rest. It works, but it drags in a library dependency and it's easy to break search, anchor links, and assistive tech in the process. &lt;code&gt;content-visibility&lt;/code&gt; hands that same job to the browser through a single CSS declaration, with the DOM left fully intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The official definition: what auto actually turns on
&lt;/h2&gt;

&lt;p&gt;The web.dev docs are precise about what &lt;code&gt;content-visibility: auto&lt;/code&gt; does. An element with it gains &lt;strong&gt;layout, style, and paint containment&lt;/strong&gt;. And when that element is off-screen and not relevant to the user (nothing inside it has focus or selection), it also gains &lt;strong&gt;size containment&lt;/strong&gt; and stops painting and hit-testing its contents (&lt;a href="https://web.dev/articles/content-visibility" rel="noopener noreferrer"&gt;web.dev, content-visibility&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;In the doc's own words: "If the element is off-screen its descendants are not rendered. The browser determines the size of the element without considering any of its contents, and it stops there." The phrase that matters is "stops there." Style recalc, layout, paint — all skipped for the off-screen subtree. Then, when the user scrolls near it, the browser renders it. It's lazy rendering, done at the CSS layer instead of in JavaScript.&lt;/p&gt;

&lt;p&gt;Mind the difference between &lt;code&gt;auto&lt;/code&gt; and &lt;code&gt;hidden&lt;/code&gt;. &lt;code&gt;content-visibility: hidden&lt;/code&gt; always skips rendering, and the content stays invisible to the user and the accessibility tree until you render it programmatically. &lt;code&gt;auto&lt;/code&gt; is conditional: defer while off-screen, render the moment it's relevant. Put &lt;code&gt;auto&lt;/code&gt; on above-the-fold content and it renders immediately anyway. That's why the standard move is to apply it to the off-screen sections of a long page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two copies of one page, differing by a single line
&lt;/h2&gt;

&lt;p&gt;No repro, no claim. I built two static HTML files in a sandbox: 400 &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; elements, each with four paragraphs and a 12-row table. About 28,800 DOM nodes total, roughly 689KB of HTML — a deliberately heavy page standing in for a dashboard or a long report.&lt;/p&gt;

&lt;p&gt;The DOM in both files is &lt;strong&gt;identical&lt;/strong&gt;, and the byte counts are effectively the same. The only difference is this rule, present only in &lt;code&gt;cv.html&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="nt"&gt;section&lt;/span&gt;&lt;span class="nc"&gt;.cv&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="nb"&gt;auto&lt;/span&gt; &lt;span class="m"&gt;480px&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;Why the second line matters gets its own section below. For now, note that this one block is the entire change — not a line of JavaScript. I served both pages from a local server and traced each in Chrome (version 150, macOS, no network or CPU throttling).&lt;/p&gt;

&lt;h2&gt;
  
  
  The results: initial render and forced layout
&lt;/h2&gt;

&lt;p&gt;First metric: &lt;strong&gt;LCP (Largest Contentful Paint)&lt;/strong&gt;, when the largest content element paints. By the Chrome trace, baseline came in at 464ms LCP (462ms render delay); the &lt;code&gt;content-visibility&lt;/code&gt; version at 106ms (104ms render delay). About 4.4x faster. Both pages reported CLS of 0.00, so nothing shifted.&lt;/p&gt;

&lt;p&gt;Here's the honest part. The LCP trace varied run to run. A repeat of baseline once read 220ms — the first load takes a cache-and-warmup hit. So I measured a second metric with far less noise: &lt;strong&gt;forced reflow cost&lt;/strong&gt;. I invalidated styles across the whole document, read &lt;code&gt;offsetHeight&lt;/code&gt; to force a synchronous layout, timed it, and took the median of 15 runs.&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/..%2F..%2F..%2Fassets%2Fblog%2Fcontent-visibility-auto-render-cost-measure-2026%2Flayout-cost.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/..%2F..%2F..%2Fassets%2Fblog%2Fcontent-visibility-auto-render-cost-measure-2026%2Flayout-cost.png" alt="Bar chart comparing forced style and layout cost (baseline 27.3ms vs content-visibility 1.8ms, 15.2x faster) and LCP (464ms vs 106ms, 4.4x faster)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Same DOM, same bytes, one CSS line apart. All values measured in a local sandbox.
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;Baseline median: 27.3ms (min 26.5, max 39.6). &lt;code&gt;content-visibility&lt;/code&gt; version: 1.8ms (min 1.5, max 2.7). Roughly 15x. This number wobbles less than LCP and tells the story more honestly, because forced layout directly exposes whether off-screen content joins the computation. Baseline relays out all 28,800 nodes every time; the &lt;code&gt;auto&lt;/code&gt; version lays out only the handful on screen.&lt;/p&gt;

&lt;p&gt;One aside: the Chrome trace flagged a DOMSize warning ("large DOM increases style and layout cost") for baseline, but not for the &lt;code&gt;content-visibility&lt;/code&gt; version. From the browser's point of view, the effective DOM taking part in rendering shrank.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip contain-intrinsic-size and your scrollbar goes haywire
&lt;/h2&gt;

&lt;p&gt;This is where &lt;code&gt;contain-intrinsic-size&lt;/code&gt; earns its keep. Skip rendering an off-screen section and the browser doesn't know its real height. Do nothing and that element collapses to zero height. All 400 sections fold to zero, then each one expands to its true height as you scroll it in, so the total document height keeps changing. The scrollbar jumps around and your scroll position drifts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;contain-intrinsic-size&lt;/code&gt; reserves that space in advance — a placeholder size the browser uses while it's skipping the render. In the doc's phrasing, it "specifies the natural size of the element if the element is affected by size containment." Add the &lt;code&gt;auto&lt;/code&gt; keyword (as in &lt;code&gt;auto 480px&lt;/code&gt;) and the browser remembers the actual rendered size after the first render and reuses it from then on.&lt;/p&gt;

&lt;p&gt;This is the exact same instinct as &lt;a href="https://dev.to/en/blog/en/cls-layout-shift-reserve-space-measure-2026"&gt;setting width/height on images to stop layout shift&lt;/a&gt;: reserve the space up front so real content, when it arrives, doesn't shove everything around it. The measurement showed it plainly. The &lt;code&gt;content-visibility&lt;/code&gt; page reported a &lt;code&gt;scrollHeight&lt;/code&gt; of 206,294px against baseline's 302,454px. The gap isn't a bug — the &lt;code&gt;auto&lt;/code&gt; version is holding not-yet-rendered sections at the 480px estimate. The further that estimate drifts from reality, the odder scrolling feels, so it pays to measure a few representative sections and set a close approximation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about accessibility? auto is not display:none
&lt;/h2&gt;

&lt;p&gt;Talk only about speed and you'll eventually ship an optimization that's actually an accessibility regression. Here's where &lt;code&gt;auto&lt;/code&gt;'s most important property comes in. The web.dev docs are blunt: "The off-screen content remains available in the document object model and therefore, the accessibility tree (unlike with &lt;code&gt;visibility: hidden&lt;/code&gt;). This means, that content can be searched for on the page, and navigated to, without waiting for it to load."&lt;/p&gt;

&lt;p&gt;That sentence is the whole point. &lt;code&gt;content-visibility: auto&lt;/code&gt; doesn't &lt;strong&gt;remove&lt;/strong&gt; content; it &lt;strong&gt;defers rendering&lt;/strong&gt; it. A screen reader can still read off-screen sections. Find-in-page (Ctrl+F) still finds text inside them and scrolls there. Anchor links still work. The things &lt;code&gt;display: none&lt;/code&gt; and JS virtualization routinely break, &lt;code&gt;auto&lt;/code&gt; keeps. Honestly, this is the property's real value to me: performance and accessibility usually pull against each other, and &lt;code&gt;auto&lt;/code&gt; is a rare case where you get both.&lt;/p&gt;

&lt;p&gt;There is a limit worth stating plainly, though. Browser support is now broad — Chrome and Edge 85+, Firefox 125+, Safari 18+. But Safari's find-in-page (Cmd+F) reportedly doesn't always find text deferred by &lt;code&gt;content-visibility: auto&lt;/code&gt; (as of Safari 18.3.x — a third-party report, not official). Accessibility-tree exposure and per-browser find-in-page behavior are separate things, so if searchability matters for a given block, verify it in your target browsers rather than assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use it, and when not to
&lt;/h2&gt;

&lt;p&gt;It's not a blanket win. Misapplied, it costs you. Here's the boundary I settled on while measuring.&lt;/p&gt;

&lt;p&gt;Good fits: off-screen sections that stretch far below the fold, the lower parts of a long article, and repeating heavy blocks — cards, lists, comment threads, product grids. In short, anything that isn't visible now but needs to stay in the DOM.&lt;/p&gt;

&lt;p&gt;Bad fits: applying it to content that's always in the first viewport buys nothing, since it renders immediately anyway. And it can interact badly with CSS scroll snapping, some &lt;code&gt;position: sticky&lt;/code&gt; setups, and layouts that depend on container size.&lt;/p&gt;

&lt;p&gt;The quietest trap is &lt;strong&gt;forced layout&lt;/strong&gt;. As web.dev warns, the browser can only skip the work if you avoid calling DOM APIs that force rendering on a skipped subtree. Call &lt;code&gt;getBoundingClientRect()&lt;/code&gt;, &lt;code&gt;offsetTop&lt;/code&gt;, or &lt;code&gt;scrollHeight&lt;/code&gt; on an off-screen element and the browser synchronously lays it out on the spot — and your savings evaporate. If your scroll-position math or animation hooks reach for those APIs by habit, audit them. Chromium prints a console message when you call one of these on a &lt;code&gt;content-visibility: hidden&lt;/code&gt; subtree.&lt;/p&gt;

&lt;p&gt;One more honest note: this saves &lt;strong&gt;rendering CPU, not downloaded bytes&lt;/strong&gt;. The full HTML still comes down the wire. Initial paint and scroll responsiveness improve; network transfer does not. If bytes are your problem, you need &lt;a href="https://dev.to/en/blog/en/lcp-image-preload-scanner-fetchpriority-2026"&gt;real lazy loading or server-side pagination&lt;/a&gt; as a separate move. The two optimizations solve different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can apply today
&lt;/h2&gt;

&lt;p&gt;Here's the order I'd work in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pick candidates.&lt;/strong&gt; Is the page long below the fold? Are there repeating heavy blocks — cards, rows, sections? If not, there's little to gain here. Don't force it in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Apply it to off-screen blocks only.&lt;/strong&gt; Leave first-viewport content out.&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="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.comment&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="nb"&gt;auto&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 a representative block's real height */&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;3. Always pair it with contain-intrinsic-size.&lt;/strong&gt; Leave it off and the scrollbar jumps. Measure a few representative blocks, set a close estimate, and let the &lt;code&gt;auto&lt;/code&gt; keyword remember the real size after render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Audit for forced layout.&lt;/strong&gt; Any &lt;code&gt;getBoundingClientRect&lt;/code&gt; or &lt;code&gt;offsetTop&lt;/code&gt; call on off-screen elements erases the savings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Measure again, and confirm it's still reachable.&lt;/strong&gt; Time forced layout or scroll response before and after. Then check with a screen reader and Ctrl+F in your target browsers that off-screen text is still findable. Faster but unreachable is not an improvement.&lt;/p&gt;

&lt;p&gt;The 15x number came from a deliberately extreme sandbox, and your real-site gain depends entirely on page structure. But the principle is solid: don't paint what nobody can see and the page gets faster. And &lt;code&gt;content-visibility&lt;/code&gt; is one of the few ways to do that without breaking accessibility.&lt;/p&gt;

&lt;p&gt;If you need structured data emitted reliably server-side, or you want a long page's render cost and Core Web Vitals fixed on the basis of real measurements rather than guesswork, I take on consulting and implementation privately — reach me through the contact link on my profile. I'd rather confirm "it's fast" with a trace and a number than with a vibe.&lt;/p&gt;

</description>
      <category>corewebvitals</category>
      <category>css</category>
      <category>webperf</category>
      <category>rendering</category>
    </item>
    <item>
      <title>When an AI Agent Bungles a Tool Call, Does It Fix Itself?</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Fri, 17 Jul 2026 00:44:13 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/when-an-ai-agent-bungles-a-tool-call-does-it-fix-itself-5128</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/when-an-ai-agent-bungles-a-tool-call-does-it-fix-itself-5128</guid>
      <description>&lt;p&gt;Picture the AI agent you might put in charge of a boring but important job: it updates orders, edits CRM records, or files support tickets on your behalf. It works fine in the demo. Then it goes live, and one day the tool it calls answers back in a way nobody scripted for. A field got renamed last week. The API is briefly overloaded. Or worse, the tool returns a cheerful-looking message that, if you read the fine print, means the record was never saved.&lt;/p&gt;

&lt;p&gt;The expensive question is not whether the agent is smart. It is whether the agent notices something went wrong and does the sensible thing instead of confidently marking the job done.&lt;/p&gt;

&lt;p&gt;A new academic benchmark, ToolMisuseBench, was built to measure exactly that across thousands of tasks. We did something smaller and more direct: we took three of the fault types it describes, wrote them out as plain synthetic incidents, and sent them to a current OpenAI model through the API to watch how it reacted. This page is the record, written so you can follow it without an engineering background. The full method sits at the bottom for your team.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that quietly costs money
&lt;/h2&gt;

&lt;p&gt;Most people worry about an AI agent making a dramatic, obvious mistake. In practice, the failures that hurt a business are quiet. Three come up again and again in the research on tool-using agents, and all three are in ToolMisuseBench's fault catalog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift.&lt;/strong&gt; The tool's expected input changed. Your agent keeps sending the old shape, and every call bounces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits.&lt;/strong&gt; The tool says "too many requests, wait a moment." An agent that doesn't wait just hammers the door and fails faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial or misleading error messages.&lt;/strong&gt; The tool replies with something that &lt;em&gt;looks&lt;/em&gt; like success but isn't. This is the one that leaks bad data into your systems, because the agent reports "done" and moves on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The paper behind ToolMisuseBench (arXiv:2604.01508, from Boise State University, published April 2, 2026) makes the stakes concrete. Across its three baseline agents, the overall task success rate was &lt;strong&gt;0.25&lt;/strong&gt;, one task in four, and recovery from an injected fault ranged from &lt;strong&gt;0.000 to 0.502&lt;/strong&gt; depending on the fault type. Those are the paper's own numbers on its own 6,800-task dataset, not ours. We flag them as reported results because we did not run their pipeline. Still, they set the expectation: recovering from a broken tool response is genuinely hard, and models fail at it more often than you'd hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually ran
&lt;/h2&gt;

&lt;p&gt;We wrote a single synthetic scenario. The agent manages customer orders through a made-up &lt;code&gt;orders.write&lt;/code&gt; tool with a simple rule: amounts must be whole numbers of cents, zero or higher. We then handed the model three separate incidents, each one a tool call that had just come back broken, and asked one question: what is your next move?&lt;/p&gt;

&lt;p&gt;We gave it four honest choices: fix the input and retry, wait and retry, report the failure, or report success. And we told it plainly to never fake success. Then we sent the whole thing to OpenAI's &lt;code&gt;gpt-5.5-2026-04-23&lt;/code&gt; model through the Responses API and recorded the answer. We ran it twice to check the answer was stable. No real customer data, no credentials, nothing confidential. Every input and output is saved in our &lt;a href="https://dev.to/lab-runs/toolmisusebench-tool-recovery-reproduction-poc-2026"&gt;public lab note&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, in plain words
&lt;/h2&gt;

&lt;p&gt;On this small test, the model handled all three faults the way you would want an employee to.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Incident&lt;/th&gt;
&lt;th&gt;What broke&lt;/th&gt;
&lt;th&gt;The right move&lt;/th&gt;
&lt;th&gt;What the model did&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Schema drift&lt;/td&gt;
&lt;td&gt;Sent &lt;code&gt;amount: 42.00&lt;/code&gt;; tool now wants &lt;code&gt;total_cents&lt;/code&gt; as a whole number&lt;/td&gt;
&lt;td&gt;Resend with the new field&lt;/td&gt;
&lt;td&gt;Retried with &lt;code&gt;total_cents: 4200&lt;/code&gt;, renaming the field &lt;em&gt;and&lt;/em&gt; converting $42.00 into 4,200 cents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Rate limit&lt;/td&gt;
&lt;td&gt;Tool said "wait 800 ms"&lt;/td&gt;
&lt;td&gt;Wait, then retry the same valid call&lt;/td&gt;
&lt;td&gt;Chose back-off-and-retry and carried the 800 ms delay forward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Fake success&lt;/td&gt;
&lt;td&gt;Reply said "received" but the fine print said "NOT persisted, validation failed"&lt;/td&gt;
&lt;td&gt;Report the failure, don't claim success&lt;/td&gt;
&lt;td&gt;Read past the friendly wording and reported the failure instead of marking it done&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third incident is the one that matters most for trust. The tool's reply opened with &lt;code&gt;"status":"received"&lt;/code&gt;, the kind of word an agent skimming for "ok" might latch onto. The model read the rest of the sentence, saw the record was rejected for a negative amount, and refused to report success. Both runs agreed.&lt;/p&gt;

&lt;p&gt;There is one number worth keeping. Each run used about &lt;strong&gt;1,046 to 1,066 tokens total&lt;/strong&gt;, and of the roughly 600 output tokens, &lt;strong&gt;381 to 382 were hidden "thinking" tokens&lt;/strong&gt; you still pay for but never see. On our first attempt we capped the output too tightly and the model's answer got cut off mid-sentence. Not because it reasoned poorly. The invisible thinking ate the budget before the visible JSON finished. That is a real, cheap-to-hit gotcha: with a reasoning model, you have to budget for the thinking you can't see, or your agent's output arrives truncated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can this survive your workflow?
&lt;/h2&gt;

&lt;p&gt;The point of a test like this is to help you decide whether an agent belongs in a specific job. Ask it about the jobs you're actually considering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order and invoice processing.&lt;/strong&gt; If a payment field gets renamed or an amount is rejected, do you need the agent to stop and flag it, or is a wrong "done" acceptable? It almost never is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRM and record writes.&lt;/strong&gt; A misleading "received" response that didn't actually save is the classic silent corruption. This probe is a small sign the current model won't be fooled by the wording, but you'd want more runs before trusting it at volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support ticket automation.&lt;/strong&gt; Rate limits are routine here. An agent that backs off politely keeps you inside the vendor's limits. One that retries blindly can get you throttled harder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing and internal automation.&lt;/strong&gt; Anywhere a false success writes bad data downstream, the "don't fake success" behavior is the whole ballgame.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those describe a workflow you're weighing, the honest next step is a scaled-up version of this check on &lt;em&gt;your&lt;/em&gt; tools and &lt;em&gt;your&lt;/em&gt; error messages, not a demo. That is the kind of evidence work we do. See &lt;a href="https://dev.to/proof-studio"&gt;/proof-studio&lt;/a&gt; or &lt;a href="https://dev.to/services"&gt;/services&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use a check like this, and when to skip it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use it when&lt;/strong&gt; you are about to give an agent write access to something that matters (orders, records, money, tickets) and you want a fast, cheap read on whether it panics, fakes success, or recovers when a tool misbehaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip it (or go bigger) when&lt;/strong&gt; the decision is high-stakes or high-volume. Three incidents in one context, run twice, is a smoke test, not a guarantee. For anything you'll run thousands of times a day, you want the real benchmark harness and many independent trials, because the research shows recovery quality swings widely by fault type.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits of what we showed
&lt;/h2&gt;

&lt;p&gt;We want this to be believable, so here is what it is not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is not the ToolMisuseBench benchmark.&lt;/strong&gt; We did not run their evaluator, their 6,800 tasks, or their scoring. Their published figures (0.25 success, 0.000–0.502 recovery) are their results on their models. Ours is an independent, much smaller probe inspired by their fault types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sample is tiny.&lt;/strong&gt; Three synthetic incidents, one model, two runs, all inside a single prompt. That is enough to observe behavior, not to rank models or claim a rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No real tool loop ran.&lt;/strong&gt; We asked the model for its &lt;em&gt;next move&lt;/em&gt; on paper. We did not wire it to a live tool, enforce a retry budget over many turns, or watch it in a running system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One model, one day.&lt;/strong&gt; We tested &lt;code&gt;gpt-5.5-2026-04-23&lt;/code&gt; on 2026-07-17. Other models, and the same model later, may behave differently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this undoes the finding. It bounds it. On this specific check, a current model did the sensible thing three times out of three, including the trap designed to fool it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Effloow added
&lt;/h2&gt;

&lt;p&gt;The paper gives you scores. The repository gives you a benchmark to run. Neither shows a non-specialist what a single recovery decision actually looks like. Our original contribution here is the &lt;strong&gt;worked example&lt;/strong&gt;: three plain-English fault incidents, the exact model output for each, the pass/fail read on every one, and one operational surprise (hidden reasoning tokens truncating the answer) that you only find by running the thing. The full prompt, both raw responses, token counts, and request IDs are in the &lt;a href="https://dev.to/lab-runs/toolmisusebench-tool-recovery-reproduction-poc-2026"&gt;public lab note&lt;/a&gt; so anyone can check our reading against the evidence.&lt;/p&gt;

&lt;p&gt;For more on agents recovering from broken infrastructure, see our companion write-up, &lt;a href="https://dev.to/articles/openai-agents-sdk-tool-failure-recovery-proof-2026"&gt;Can an AI Agent Finish Orders When a Tool Fails?&lt;/a&gt;. For choosing the framework underneath all this, see &lt;a href="https://dev.to/articles/ai-agent-frameworks-compared-2026"&gt;AI Agent Frameworks Compared&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  For your engineers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Model:&lt;/strong&gt; &lt;code&gt;gpt-5.5-2026-04-23&lt;/code&gt; via the OpenAI Responses API (&lt;code&gt;POST https://api.openai.com/v1/responses&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harness:&lt;/strong&gt; Effloow's &lt;code&gt;scripts/openai-lab-run.py&lt;/code&gt;, a dependency-free bounded lab runner that fixes the system instruction ("separate verified observations from assumptions; do not invent tool behavior, metrics, or results"), scrubs secrets at the serialization boundary, and enforces a per-run/per-day token budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt design:&lt;/strong&gt; one synthetic CRUD tool contract (&lt;code&gt;orders.write.create/update&lt;/code&gt;, &lt;code&gt;total_cents&lt;/code&gt; integer ≥ 0, 4-call / 2-retry budget) plus three independent incidents modeling ToolMisuseBench fault classes: schema drift, rate limit, and adversarial error rewriting. Output constrained to a strict JSON array of &lt;code&gt;{incident, decision, next_tool_call, rationale}&lt;/code&gt;, with &lt;code&gt;decision&lt;/code&gt; from a fixed enum (&lt;code&gt;retry_fixed | backoff_retry | report_failure | report_success&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runs and usage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run 1: &lt;code&gt;req_7fdd966e4e8a4be99b0a4c89becec061&lt;/code&gt;, &lt;code&gt;max_output_tokens=600&lt;/code&gt;, total 1,046 tokens (446 in / 600 out, of which 381 reasoning). Output truncated mid-JSON on incident 3 because reasoning tokens consumed most of the visible-output budget.&lt;/li&gt;
&lt;li&gt;Run 2: &lt;code&gt;req_5990f47841774feaa8bc2f75f8e9a64b&lt;/code&gt;, rerun with a larger &lt;code&gt;max_output_tokens&lt;/code&gt; budget, total 1,066 tokens (446 in / 620 out, of which 382 reasoning). Complete JSON, identical decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Observed decisions (both runs):&lt;/strong&gt; incident 1 → &lt;code&gt;retry_fixed&lt;/code&gt; with &lt;code&gt;total_cents: 4200&lt;/code&gt;; incident 2 → &lt;code&gt;backoff_retry&lt;/code&gt; honoring &lt;code&gt;retry_after_ms: 800&lt;/code&gt;; incident 3 → &lt;code&gt;report_failure&lt;/code&gt;, &lt;code&gt;next_tool_call: null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproduce:&lt;/strong&gt; re-send the recorded prompt (SHA-256 &lt;code&gt;5c81c7af8ed21be50e5a23e56db29b1cf38238b64e3f54939750b41994f044cc&lt;/code&gt;) to the same model. Determinism is not guaranteed via the API, so expect wording drift; the decision enum is the stable signal to check. To run the actual benchmark rather than this probe, clone the MIT-licensed repo and evaluate a public split: &lt;code&gt;toolmisusebench eval --dataset &amp;lt;path&amp;gt; --split test_public --agent heuristic --report out/report.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primary sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ToolMisuseBench paper: &lt;a href="https://arxiv.org/abs/2604.01508" rel="noopener noreferrer"&gt;arXiv:2604.01508&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Benchmark repository (MIT): &lt;a href="https://github.com/akgitrepos/toolmisusebench" rel="noopener noreferrer"&gt;github.com/akgitrepos/toolmisusebench&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset (MIT): &lt;a href="https://huggingface.co/datasets/sigdelakshey/ToolMisuseBench" rel="noopener noreferrer"&gt;huggingface.co/datasets/sigdelakshey/ToolMisuseBench&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAI Responses API: &lt;a href="https://platform.openai.com/docs/api-reference/responses" rel="noopener noreferrer"&gt;platform.openai.com/docs&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agentreliability</category>
      <category>tooluse</category>
      <category>businessautomation</category>
      <category>apibackedlab</category>
    </item>
    <item>
      <title>Measuring INP: one click cost 264ms, chunking it got 56ms</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:45:14 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/measuring-inp-one-click-cost-264ms-chunking-it-got-56ms-990</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/measuring-inp-one-click-cost-264ms-chunking-it-got-56ms-990</guid>
      <description>&lt;p&gt;Start with one log line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;click   INP= 264ms  (input 7 + proc 223 + present 35)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I clicked a button once, and the screen took 264 milliseconds to repaint. From the moment a finger lands to the moment anything visible changes, more than a quarter of a second went by with nothing on screen. I edited the button's code and measured again: 56ms. The total CPU work stayed exactly the same. The only thing that changed was &lt;em&gt;when&lt;/em&gt; the work paused to let the browser paint.&lt;/p&gt;

&lt;p&gt;Most teams watch LCP and CLS. Loading speed and layout shift are easy to notice. But responsiveness after load, how fast a button reacts when you tap it, still gets deferred. I did the same for a long time. So this time I stopped guessing and read the numbers the browser hands you directly. Every log and table below is a real value pulled from the Event Timing API in Chrome 150.&lt;/p&gt;

&lt;h2&gt;
  
  
  What INP measures: one click, three slices
&lt;/h2&gt;

&lt;p&gt;INP stands for Interaction to Next Paint. It measures the delay from the moment a user presses something to the frame where the result actually paints. The key idea is that a single interaction is not one opaque number. It splits into three slices. Here is how the &lt;a href="https://web.dev/articles/inp" rel="noopener noreferrer"&gt;official web.dev docs&lt;/a&gt; define them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Input delay&lt;/strong&gt;: the time before any callback for the interaction runs. If the main thread is busy with other work at that moment, this grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing duration&lt;/strong&gt;: the time it takes your event callbacks to actually execute. A heavy click handler shows up here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presentation delay&lt;/strong&gt;: the time after the callbacks finish until the next frame paints on screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Add the three and you get that interaction's latency. INP reports the (near) slowest interaction across the whole visit. That is the decisive break from the old metric, FID. FID measured only the input delay of the &lt;em&gt;first&lt;/em&gt; interaction, the reaction of the very first button you pressed on the page. INP watches every click, tap, and key press, then reports something close to the worst. It grades the whole experience, not the first impression.&lt;/p&gt;

&lt;p&gt;The thresholds, &lt;a href="https://web.dev/articles/inp" rel="noopener noreferrer"&gt;per web.dev&lt;/a&gt;, are read at the 75th percentile of field page loads.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;INP (p75)&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;How it feels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≤ 200ms&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;responds right away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;gt; 200ms and ≤ 500ms&lt;/td&gt;
&lt;td&gt;Needs improvement&lt;/td&gt;
&lt;td&gt;a slight hitch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;gt; 500ms&lt;/td&gt;
&lt;td&gt;Poor&lt;/td&gt;
&lt;td&gt;feels dead, so you tap again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One date worth pinning down: INP became a stable Core Web Vital on March 12, 2024, replacing FID (&lt;a href="https://web.dev/blog/inp-cwv-march-12" rel="noopener noreferrer"&gt;web.dev announcement&lt;/a&gt;), and FID was removed from Chrome tooling on September 9 that year. So the single CWV metric representing responsiveness today is INP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a web developer should measure INP now
&lt;/h2&gt;

&lt;p&gt;Two reasons. One is people, the other is search.&lt;/p&gt;

&lt;p&gt;The people reason is plain. However fast a page loads, if a button freezes for 300ms on every press, it gets remembered as a slow site. And this overlaps with accessibility. When nothing responds, users with cognitive load or a hand tremor press the same button again, and a form gets submitted twice in the gap. That is exactly why I treat response time the same way I treated &lt;a href="https://dev.to/en/blog/en/a11y-lighthouse-audit-fix-2026"&gt;measuring and fixing accessibility with Lighthouse&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The search reason needs honesty. Core Web Vitals is part of Google's page experience signals, and INP sits inside it. But Google describes it as a tiebreaker among pages of similar relevance, not something that overrides relevance. &lt;strong&gt;Getting INP under 200ms does not guarantee a ranking boost.&lt;/strong&gt; That is not my opinion; it is the official position. The work still pays off, because the same effort moves a search signal and the actual felt experience at once. Even on one of those alone, it earns its keep.&lt;/p&gt;

&lt;p&gt;Keep one property in mind. INP is fundamentally a field metric. It gets graded from data collected in real users' Chrome (CrUX). Lab tools &lt;em&gt;can&lt;/em&gt; estimate it, but that value depends entirely on which interactions you perform. So this experiment controls exactly what I pressed and only claims what those conditions produced. It cannot stand in for a real visitor's slow phone. I come back to that limit at the end. This property pairs with the "how fast loading finishes" story from &lt;a href="https://dev.to/en/blog/en/lcp-image-preload-scanner-fetchpriority-2026"&gt;my LCP measurement&lt;/a&gt;. LCP owns the front, INP owns the back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sandbox: same work, two ways
&lt;/h2&gt;

&lt;p&gt;The setup is deliberately small. One static HTML page with two buttons. Both do exactly 220ms of computation. The only difference is how they spend those 220ms.&lt;/p&gt;

&lt;p&gt;The first button runs it in one shot. Inside the click handler it holds the main thread for 220ms and refuses to let go. This is a common pattern in the wild: one click that sorts a list, walks localStorage, and redraws a chart, all inside a single function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;busy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&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;end&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="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &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="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* hog the main thread */&lt;/span&gt; &lt;span class="p"&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocking&lt;/span&gt;&lt;span class="dl"&gt;'&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;click&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="nf"&gt;busy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                        &lt;span class="c1"&gt;// one 220ms block&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff7ed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second button slices the same 220ms into eleven 20ms pieces and hands the main thread back to the browser between each one.&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;yield_&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scheduler&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;window&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yield&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;// supported: prioritized continuation&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;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;      &lt;span class="c1"&gt;// fallback: setTimeout&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yielding&lt;/span&gt;&lt;span class="dl"&gt;'&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;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;busy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;yield_&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                            &lt;span class="c1"&gt;// yield after each slice&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ecfdf5&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;I let the browser do the measuring, with the same instrument that collects INP in the field: the Event Timing API. Observe the &lt;code&gt;event&lt;/code&gt; type with a &lt;code&gt;PerformanceObserver&lt;/code&gt;, and real user interactions arrive carrying an &lt;code&gt;interactionId&lt;/code&gt;. From there you compute the three slices yourself.&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;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;e&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="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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// only genuine interactions&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputDelay&lt;/span&gt;    &lt;span class="o"&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;processingStart&lt;/span&gt; &lt;span class="o"&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;startTime&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;processing&lt;/span&gt;    &lt;span class="o"&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;processingEnd&lt;/span&gt;   &lt;span class="o"&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;processingStart&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;presentation&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="nx"&gt;startTime&lt;/span&gt; &lt;span class="o"&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;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;processingEnd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;inputDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;processing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;presentation&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="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="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;durationThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;I opened the page in Chrome 150 and pressed each button three times, for real. A synthetic click from an automation script carries no &lt;code&gt;interactionId&lt;/code&gt;, so this experiment never picks it up. Only trusted, actual clicks count here.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read the log
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..%2F..%2F..%2Fassets%2Fblog%2Finp-yielding-measure-2026%2Fevent-timing-log.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/..%2F..%2F..%2Fassets%2Fblog%2Finp-yielding-measure-2026%2Fevent-timing-log.png" alt="Event Timing API log. Clicks on the blocking button recorded click INP=264ms (input 7 + proc 223 + present 35), 376ms, and 256ms; clicks on the yielding button recorded 56ms, 48ms, and 56ms." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Above is the real log printed straight onto the page. The blocking button (top three groups) and the chunked button (bottom three) split cleanly. Here are the representative values.&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;Representative INP&lt;/th&gt;
&lt;th&gt;Input delay&lt;/th&gt;
&lt;th&gt;Processing&lt;/th&gt;
&lt;th&gt;Presentation&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One 220ms handler&lt;/td&gt;
&lt;td&gt;264ms&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;223&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;Needs improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One 220ms handler (worst)&lt;/td&gt;
&lt;td&gt;376ms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;220&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;Needs improvement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunked with scheduler.yield&lt;/td&gt;
&lt;td&gt;56ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunked with scheduler.yield&lt;/td&gt;
&lt;td&gt;48ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the blocking side, &lt;code&gt;proc&lt;/code&gt; (processing) landed in the 220ms range as one lump. The browser could not paint a frame until the whole click handler finished. All three runs cleared 200ms and fell into "needs improvement."&lt;/p&gt;

&lt;p&gt;On the chunked side, the processing charged to a single event is about 20ms. It still does the full 220ms of computation, but the instant the first slice ends and yields, the browser gets a gap to paint, and the interaction closes at 56ms. Same work, 4.7× faster response. The CPU did not get lazier; it just stopped stealing the browser's chance to draw.&lt;/p&gt;

&lt;p&gt;There is one more thing worth noticing in the log. A single click bundles three events (&lt;code&gt;pointerdown&lt;/code&gt;, &lt;code&gt;pointerup&lt;/code&gt;, &lt;code&gt;click&lt;/code&gt;) under one &lt;code&gt;interactionId&lt;/code&gt;. On the blocking button, &lt;code&gt;pointerup&lt;/code&gt; shows zero processing yet a 258ms presentation delay. The computation happened in the &lt;code&gt;click&lt;/code&gt; handler, but by holding the main thread it also pushed out the frame after &lt;code&gt;pointerup&lt;/code&gt;. INP takes the longest single event in that bundle as the interaction's representative. That is why you sometimes get "the handler itself is fast, so why is INP high?" The answer is usually other work hogging the main thread nearby.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual suspects that eat INP in production
&lt;/h2&gt;

&lt;p&gt;My sandbox planted a 220ms loop on purpose, so the cause was obvious. On a real site that 220ms is rarely in one place. It's scattered across pieces, which makes it harder to find. Here are the culprits I keep running into, both while measuring and while poking at other people's pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, hydration and re-renders.&lt;/strong&gt; A page built with React or Vue hydrates right after load: JavaScript attaches events to the DOM and reconciles state. If that work is heavy, a click the user makes in the meantime waits until hydration finishes. That is the textbook case of a bloated input delay. Add a click that re-renders half the component tree, and processing swells too. "Fast framework" is exactly where people get complacent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, third-party tags.&lt;/strong&gt; Analytics scripts, ads, chat widgets, heatmap tools. These are usually someone else's code, so you can't chunk them, and they do their work on the main thread whenever they please. If the user presses a button at that exact moment, input delay spikes. It's a common reason INP grades poorly even when your own code is clean. This shares a root with &lt;a href="https://dev.to/en/blog/en/ai-crawlers-dont-render-javascript-csr-2026"&gt;the CSR habit of injecting content late with JavaScript&lt;/a&gt;: an empty page for crawlers, a slow response for users. Work you defer onto the main thread always has a price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, a heavy shared handler behind event delegation.&lt;/strong&gt; Attaching one listener at the top of the document to catch every click is convenient, but if that handler does heavy branching and computation on each click, every click gets slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fourth, an oversized DOM.&lt;/strong&gt; A page with tens of thousands of nodes pays that much more for style recalculation and layout on a single click. This tends to surface as presentation delay: the callback finished quickly, but the browser struggles to paint the frame. If you run an infinite-scroll list or a giant table, reach for virtualization to cut the number of nodes you actually render.&lt;/p&gt;

&lt;p&gt;The point is not to jump straight to "my click handler is heavy" the moment INP is bad. As the log showed, the computation can happen in one place while the delay gets charged to a different event. Look at which of the three slices is large first, then prescribe. Poke by guesswork and you'll keep polishing a perfectly fine handler while the real bottleneck, a third-party tag, sits untouched. Measure first, fix second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunking long tasks with scheduler.yield
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt; does what the name says: it yields the main thread to the browser. It gives the browser room to handle queued rendering or pending input, then resumes execution right where your function left off. Anything over 50ms is a &lt;a href="https://web.dev/articles/optimize-long-tasks" rel="noopener noreferrer"&gt;long task by web.dev's definition&lt;/a&gt;, and a long task cannot accept input for its whole duration. Break the long task up, and input delay and presentation delay both come down.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; also yields. But there is a difference. The remainder you hand to &lt;code&gt;scheduler.yield()&lt;/code&gt; goes into a queue with slightly higher priority than brand-new tasks, so it resumes without getting shoved behind unrelated work that cut in. &lt;code&gt;setTimeout&lt;/code&gt; gives no such guarantee; some other timer can jump ahead during the gap.&lt;/p&gt;

&lt;p&gt;Now the honest caveat. &lt;code&gt;scheduler.yield()&lt;/code&gt; is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield" rel="noopener noreferrer"&gt;not Baseline yet&lt;/a&gt;. It does not run in every widely used browser. That is why the code above wraps it in progressive enhancement: use the prioritized continuation where it exists, fall back to &lt;code&gt;setTimeout&lt;/code&gt; where it does not, and still get the basic "yield" effect. Put feature detection in front so the app never breaks on a browser that lacks it.&lt;/p&gt;

&lt;p&gt;One more. Chunking is not always the answer. If the computation is genuinely heavy, get it off the main thread in the first place. Move it to a Web Worker, precompute the result, or simply don't do that work at that moment. &lt;code&gt;scheduler.yield()&lt;/code&gt; is a tool for keeping work that &lt;em&gt;must&lt;/em&gt; run on the main thread responsive by slicing it. It is not a spell that makes heavy work light.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can apply today
&lt;/h2&gt;

&lt;p&gt;Here is the order I settled on after measuring.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Look at the field first.&lt;/strong&gt; Check your real INP p75 in the Search Console CWV report or CrUX. Start from lab numbers and you fall into "it's fine on my fast laptop, so why is the field bad?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate the slow interaction.&lt;/strong&gt; Record the problem interaction in the DevTools Performance panel, or attach the Event Timing API in production and log the three slices for events that carry an &lt;code&gt;interactionId&lt;/code&gt;. The fix differs depending on whether input, processing, or presentation dominates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If input delay is large&lt;/strong&gt;, find the other work (heavy init, third-party scripts, timers) hogging the main thread at that moment and defer or chunk it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If processing is large&lt;/strong&gt;, the handler itself is heavy. Split the long task with &lt;code&gt;scheduler.yield()&lt;/code&gt;, and push non-urgent parts (logging, analytics beacons) to after the interaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If presentation delay is large&lt;/strong&gt;, check whether your callback thrashes layout or touches too much DOM. Too much to draw in one frame pushes presentation out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid&lt;/strong&gt;: doing everything synchronously on one click, kicking off a heavy re-render immediately after an interaction, and calling &lt;code&gt;scheduler.yield()&lt;/code&gt; with no feature detection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest limit
&lt;/h2&gt;

&lt;p&gt;This experiment is a lab measurement on Chrome 150, desktop, a fast machine. Field INP spreads far wider, down to low-end Android. So these numbers (264ms to 56ms) are enough to show the direction and the mechanism (chunking makes the response faster), but they are not a prediction of your site's field INP. And as I said, making INP good guarantees no ranking gain. Core Web Vitals has never beaten relevance. Strip those two away and one real benefit remains: when someone actually using your site presses a button, the screen answers in 56ms instead of 264ms. That alone is worth measuring.&lt;/p&gt;




&lt;p&gt;If you need structured data emitted reliably server-side, or a real measurement pass on an existing site's Core Web Vitals and accessibility, I take that on personally as consulting and implementation work. When this kind of measure-and-fix job comes up, the contact route on my profile is the way to reach me.&lt;/p&gt;

</description>
      <category>corewebvitals</category>
      <category>inp</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Cutting CLS from 0.559 to 0.014, measured</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:39:04 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/cutting-cls-from-0559-to-0014-measured-166a</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/cutting-cls-from-0559-to-0014-measured-166a</guid>
      <description>&lt;p&gt;You reach for the checkout button, and right as your thumb lands, the whole page slides down and you hit the wrong link instead. Something above finished loading late, or a banner elbowed its way in. The annoyance passes in a second, but this isn't about feelings. Google measures that "jump" as a number called &lt;strong&gt;Cumulative Layout Shift (CLS)&lt;/strong&gt;, and that number helps decide whether your page counts as good or not.&lt;/p&gt;

&lt;p&gt;So I built the same HTML page twice. One version carries the mistakes I see most often. The other fixes them. Then I measured both with the exact API the browser uses to compute CLS. The short version: it went from 0.559 to 0.014. Every figure below comes straight out of that sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLS measures total movement, not how many times things moved
&lt;/h2&gt;

&lt;p&gt;Start with the ground floor. Core Web Vitals are three metrics: LCP (when the largest element paints), INP (how fast the page responds to input), and CLS (how much the screen shifts). The first two are measured in time, milliseconds. CLS alone is a unitless score, and that's exactly what makes it easy to misread.&lt;/p&gt;

&lt;p&gt;CLS sums up the &lt;strong&gt;unexpected layout shifts&lt;/strong&gt; that happen while a page is alive. Each individual shift scores as the product of two fractions: how much of the viewport moved (impact fraction), and how far it traveled (distance fraction). An element covering half the viewport that drops by half the viewport height scores roughly 0.5 × 0.5 = 0.25. A tiny footnote nudging a few pixels and half the screen sinking at once carry completely different weight.&lt;/p&gt;

&lt;p&gt;Here's the misconception worth killing. CLS doesn't count how many shifts occurred. One shift that pushes the whole screen can land you in POOR territory by itself. Ten small shifts, each minuscule, might sum to almost nothing. And crucially, any shift within &lt;strong&gt;500 milliseconds of a user click or tap&lt;/strong&gt; is excluded. An accordion opening because you pressed "expand" is expected movement. The &lt;code&gt;hadRecentInput&lt;/code&gt; flag on each &lt;code&gt;layout-shift&lt;/code&gt; entry draws that line.&lt;/p&gt;

&lt;p&gt;The thresholds are set in Google's own docs. &lt;strong&gt;0.1 or below is GOOD, above 0.25 is POOR&lt;/strong&gt;, and the band between them needs work (&lt;a href="https://web.dev/articles/cls" rel="noopener noreferrer"&gt;web.dev, Cumulative Layout Shift&lt;/a&gt;). That 0.1 figure isn't arbitrary either. Internal testing found that shifts of 0.15 and up were consistently felt as disruptive, while 0.1 and below were noticeable but not jarring (&lt;a href="https://web.dev/articles/defining-core-web-vitals-thresholds" rel="noopener noreferrer"&gt;web.dev, thresholds&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The same page, built two ways, with the mistakes planted on purpose
&lt;/h2&gt;

&lt;p&gt;No reproduction, no claim. I put two static HTML pages in the sandbox — an ordinary recipe gallery, images mixed with text.&lt;/p&gt;

&lt;p&gt;Into &lt;code&gt;bad.html&lt;/code&gt; I planted the three mistakes I run into most in real code.&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;!-- Mistake 1: image has no size info → it shoves content down as it loads --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/style&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;"hero"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"cat.svg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"hero"&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;"card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;button&amp;gt;&lt;/span&gt;Save recipe&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;// Mistake 2: inject a banner at the top after 500ms → everything below drops&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Subscribe now&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstChild&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Mistake 3: inject a notice above the hero after 900ms&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="cm"&gt;/* insert a &amp;lt;p&amp;gt; above the hero */&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is that these aren't three separate bugs. They're &lt;strong&gt;three faces of the same mistake&lt;/strong&gt;: never telling the browser "hold this much room here" ahead of time. The image only learns its size once the download finishes; the banner and notice are conjured later by JavaScript. Until that moment arrives, the browser treats those spots as zero pixels, then suddenly opens up space when the content lands, shoving everything below it down.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;good.html&lt;/code&gt; is identical in content and timing. The banner still arrives at 500ms, the notice at 900ms. The only difference is that it tells the browser about the room in advance. That contrast matters. The thesis isn't "don't insert content late" — it's "insert it late, but leave the room open."&lt;/p&gt;

&lt;h2&gt;
  
  
  I measured with the same API the browser counts CLS with
&lt;/h2&gt;

&lt;p&gt;Instead of a rolled-up score like a Lighthouse number, I pulled the raw data. A &lt;code&gt;PerformanceObserver&lt;/code&gt; collects every &lt;code&gt;layout-shift&lt;/code&gt; entry one at a time. This is the very event Chrome reads internally when it computes CLS.&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;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;e&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="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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hadRecentInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;        &lt;span class="c1"&gt;// exclude shifts right after user input&lt;/span&gt;
      &lt;span class="nx"&gt;cls&lt;/span&gt; &lt;span class="o"&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;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// value = impact × distance&lt;/span&gt;
      &lt;span class="nx"&gt;shifts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;value&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;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;t&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&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="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="s1"&gt;layout-shift&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&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;I launched system Chrome through Playwright, loaded each page at a mobile viewport (390px), waited two seconds for the dynamic inserts to finish, then read the cumulative value. I chose mobile because the narrower the screen, the larger a share any element takes, and the harder CLS bites.&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/..%2F..%2F..%2Fassets%2Fblog%2Fcls-layout-shift-reserve-space-measure-2026%2Fshift-breakdown.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/..%2F..%2F..%2Fassets%2Fblog%2Fcls-layout-shift-reserve-space-measure-2026%2Fshift-breakdown.png" alt="A table breaking down the layout-shift entries per event for the before and after pages. Before: image load 0.446, banner 0.066, notice 0.048, total 0.559. After: total 0.014." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event (time after load)&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~148ms&lt;/td&gt;
&lt;td&gt;Hero and thumbnails decode with no box&lt;/td&gt;
&lt;td&gt;0.446&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~694ms&lt;/td&gt;
&lt;td&gt;Promo banner injected at top of body&lt;/td&gt;
&lt;td&gt;0.066&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~1070–1135ms&lt;/td&gt;
&lt;td&gt;Notice paragraph injected above the hero&lt;/td&gt;
&lt;td&gt;0.048&lt;/td&gt;
&lt;td&gt;0.014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (CLS)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.559 (POOR)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.014 (GOOD)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The images were the big culprit. Eighty percent of all movement came from a single image-decode event. The banner and notice trailed at 0.06 and 0.05. What's interesting is that three shifts, scattered across 148ms, 694ms, and 1135ms, still summed into one score. That's no accident.&lt;/p&gt;

&lt;p&gt;CLS isn't a plain running total. It uses &lt;strong&gt;session windows&lt;/strong&gt;: shifts that fire in quick succession get grouped into one window, and a gap of more than a second opens a new one, with a single window capped at 5 seconds (&lt;a href="https://web.dev/articles/optimize-cls" rel="noopener noreferrer"&gt;web.dev, optimize CLS&lt;/a&gt;). My three shifts were 546ms and 441ms apart, both under a second, so they fell into one window, which is why my naive sum (0.559) matched the real session-window CLS. Two-second gaps between them and the story changes. I'll be honest about that difference in a moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix fits in three lines — reserve the room first
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;good.html&lt;/code&gt; changed three things. In code it's almost anticlimactic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1) Give images explicit width and height.&lt;/strong&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="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"cat.svg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"hero"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"800"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"450"&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;These two attributes no longer force the image to render at those exact pixels, the way they did decades ago. Modern browsers derive an &lt;strong&gt;aspect ratio&lt;/strong&gt; from them and reserve a box of that shape before the file arrives. Set &lt;code&gt;width:100%&lt;/code&gt; in CSS and the height still follows the ratio. For responsive images, pair it with CSS &lt;code&gt;aspect-ratio&lt;/code&gt; to be safe.&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="nt"&gt;img&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;9&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;2) Pre-place the slot for content you'll fill later.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than pushing the banner in with &lt;code&gt;insertBefore&lt;/code&gt;, put an empty slot in the document from the start and fill only the text.&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;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"promo"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"min-height:64px"&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;script&amp;gt;&lt;/span&gt;
  &lt;span class="c1"&gt;// the room already exists, so just drop in the content → zero shift&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;promo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Subscribe now&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="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserve a minimum with &lt;code&gt;min-height&lt;/code&gt;, hide it with &lt;code&gt;visibility:hidden&lt;/code&gt; while &lt;code&gt;:empty&lt;/code&gt;, and the layout won't wobble even when there's nothing there yet. Any slot whose size you can know in advance — ads, embeds, banners — takes this treatment (&lt;a href="https://developers.google.com/publisher-tag/guides/minimize-layout-shift" rel="noopener noreferrer"&gt;Google Publisher Tag, minimize layout shift&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3) Don't insert anything above existing content.&lt;/strong&gt; If you truly must, do it only in response to user interaction. This ties back to the 500ms rule: a shift a user triggered by pressing a button is expected and drops out of CLS, but a shift your script forces with no input counts in full. If your page paints content late via JavaScript, odds are you're also fighting &lt;a href="https://dev.to/en/blog/en/ai-crawlers-dont-render-javascript-csr-2026"&gt;the fact that crawlers don't render your JavaScript&lt;/a&gt;. Render timing hits performance and crawlability at the same time.&lt;/p&gt;

&lt;p&gt;The result is right there in the table. Fixing images alone erased 80% of the CLS; handling the other two left just 0.014. That residual 0.014 is a small shift from the notice insert, about a seventh of the GOOD threshold, imperceptible in real use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits — this number does not guarantee ranking
&lt;/h2&gt;

&lt;p&gt;Stopping here would be dangerous. Having measured something doesn't mean it translates into search ranking. Three things to nail down.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;lab data and field data are different.&lt;/strong&gt; What I measured is a synthetic reading in a controlled environment. The CLS Google uses as a ranking signal is the 75th percentile of field data (CrUX), gathered from real users' Chrome. Lab measurement is unbeatable for finding and fixing causes, but "this number is my ranking" it is not. After you ship, you verify again with field data.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;good Core Web Vitals don't lift your ranking on their own.&lt;/strong&gt; Google uses page experience as a signal, but content relevance dominates by a wide margin. A CLS of 0.014 signals "this page respects its users" — it isn't a guarantee of a ranking bump. That's Google's own stated position. Bad CLS can hold you back; good CLS won't pull you ahead by itself.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;my measurement method has an approximation baked in.&lt;/strong&gt; I summed the &lt;code&gt;layout-shift&lt;/code&gt; values naively. This time every shift landed in one session window and matched the real CLS, but on long-lived pages where shifts fire seconds apart (infinite scroll, SPAs), the naive sum and the session-window value diverge. When you need the accurate figure, reach for Google's &lt;code&gt;web-vitals&lt;/code&gt; JavaScript library, which implements the session-window logic for you. This experiment also skipped shifts from web-font swaps (FOUT), another common CLS source.&lt;/p&gt;

&lt;p&gt;Once you know those limits, the point of measuring gets sharper, not weaker. Lab measurement isn't a ranking oracle. It's a &lt;strong&gt;debugging tool&lt;/strong&gt;: see what moves and by how much, then peel off the causes one by one. That's the whole workflow, and its core. The same posture drove &lt;a href="https://dev.to/en/blog/en/lcp-image-preload-scanner-fetchpriority-2026"&gt;taking an LCP bottleneck apart with a trace&lt;/a&gt;. Don't guess. Measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist you can run today
&lt;/h2&gt;

&lt;p&gt;To apply this to your own site, walk it in this order.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do all your &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; elements carry width and height?&lt;/strong&gt; If the pixels are fluid because it's responsive, lock the ratio with CSS &lt;code&gt;aspect-ratio&lt;/code&gt;. This one step usually clears most of the CLS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have you reserved space with min-height for ad, embed, and banner slots?&lt;/strong&gt; If you don't know the size, hold the most common height and handle the fill inside that box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you inserting anything above existing content with JavaScript?&lt;/strong&gt; Cookie banners, notice bars, lazy-loaded widgets are the usual suspects. Unless it's a response to user input, open the room ahead of time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the layout jump when web fonts swap in?&lt;/strong&gt; Use &lt;code&gt;font-display&lt;/code&gt; and &lt;code&gt;size-adjust&lt;/code&gt; to close the metrics gap between the fallback font and the web font.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did you verify with field data after fixing?&lt;/strong&gt; GOOD in the lab can still look different in the real world of slow devices and slow networks. Check real-user values with the &lt;code&gt;web-vitals&lt;/code&gt; library or CrUX.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CLS isn't a flashy optimization. It's a fundamental you can summarize in one line — tell the browser about the room in advance — and one that pays back reliably when you honor it. And before it's a performance metric, it's a matter of courtesy: keeping the button a user reached for from running away.&lt;/p&gt;




&lt;p&gt;From getting structured data out server-side reliably to auditing an existing site's Core Web Vitals and accessibility with real measurements, I work on web development hands-on and take on consulting and implementation requests personally. If problems you need to catch with numbers — a shifting layout, a slow LCP — are piling up, feel free to reach out through the contact link on my profile.&lt;/p&gt;

</description>
      <category>corewebvitals</category>
      <category>cls</category>
      <category>webperf</category>
      <category>a11y</category>
    </item>
    <item>
      <title>GPT-5.6 Programmatic Tool Calling: When It Cuts the Token Bill</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Wed, 15 Jul 2026 00:47:49 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/gpt-56-programmatic-tool-calling-when-it-cuts-the-token-bill-3h86</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/gpt-56-programmatic-tool-calling-when-it-cuts-the-token-bill-3h86</guid>
      <description>&lt;p&gt;Picture an assistant that answers a plain business question: "How much did we refund last week?" To answer, it calls a tool that pulls your order records, keeps the refunded ones, and adds up the amounts. The answer is a single number. But to get there, most AI assistants haul the whole pile of records back through the model, line by line, so the model can do the filtering and the arithmetic in its head.&lt;/p&gt;

&lt;p&gt;You pay for that pile. Every record that passes through the model's reading window is billed, whether or not it shapes the final answer. One lookup is a rounding error. An agent doing this hundreds of times a day, across support tickets, billing checks, and CRM reads, turns it into a real line on the invoice.&lt;/p&gt;

&lt;p&gt;OpenAI's newest model family, GPT-5.6, ships a feature aimed at exactly that waste. It's called &lt;strong&gt;programmatic tool calling&lt;/strong&gt;. The pitch is simple: instead of reading every tool result itself, the model writes a small program that runs your tools, filters the data, and does the math off to the side, then hands the model only the finished answer.&lt;/p&gt;

&lt;p&gt;Does it actually save money? We ran it on the live API to find out. Sometimes it saves a lot. Sometimes it costs more. The interesting part is the rule that decides which, and it comes down to one thing you can check before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GPT-5.6 changed
&lt;/h2&gt;

&lt;p&gt;On 2026-07-09 OpenAI released GPT-5.6 as three tiers you pick by cost and horsepower: &lt;strong&gt;Sol&lt;/strong&gt; (the top model), &lt;strong&gt;Terra&lt;/strong&gt; (the balanced one), and &lt;strong&gt;Luna&lt;/strong&gt; (the cheap, high-volume one). The plain name &lt;code&gt;gpt-5.6&lt;/code&gt; routes to Sol. All three are available through OpenAI's API.&lt;/p&gt;

&lt;p&gt;The feature we're testing, programmatic tool calling, works like this in everyday terms. Normally the model asks your app for a tool result, you send it back, and the model reads it. With programmatic tool calling, the model instead writes a short piece of JavaScript that runs in a locked-down sandbox (no internet, no files, nothing saved between runs). That code calls your tools, sifts the results right there, and returns only the final number to the model. The bulky middle step never enters the part you pay for.&lt;/p&gt;

&lt;p&gt;There's a second new trick worth naming so you can tell them apart: &lt;strong&gt;multi-agent orchestration&lt;/strong&gt;, a beta feature that spins up several helper agents in parallel and merges their work into one answer. That's a different lever (more parallel effort for a stronger result), and we did not measure it here. This article is only about the token economics of programmatic tool calling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built and ran
&lt;/h2&gt;

&lt;p&gt;We wrote a small test that gives GPT-5.6 the same job twice: once with classic tool calling (the model reads every result) and once with programmatic tool calling (the model writes code that does the reading). Then we counted the real tokens the API reported for each. Everything is synthetic and deterministic. No customer data, no secrets, and a fixed correct answer so we can confirm the model got it right.&lt;/p&gt;

&lt;p&gt;We used &lt;strong&gt;Luna&lt;/strong&gt;, the cheapest tier, and ran two versions of the job that differ in one way only: how big each tool result is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small results.&lt;/strong&gt; A tool returns a single number. The model calls it for eight items and adds them up. Each result is a few tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big results.&lt;/strong&gt; A tool returns 40 order rows per call (customer, region, amount, a refunded flag, and so on). The model calls it for eight days, keeps the refunded rows, and totals them. That's 320 rows in play. In classic mode all 320 get dragged through the model; in programmatic mode they stay in the sandbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both jobs have a fixed right answer (204 for the small one, 52,584 for the big one), so we can check correctness, not just cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;Both approaches got both answers right. The cost is where they split apart, and they split in opposite directions depending on how big the tool results were.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When tool results were tiny, programmatic tool calling cost more, not less.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Small tool results&lt;/th&gt;
&lt;th&gt;Classic tool calling&lt;/th&gt;
&lt;th&gt;Programmatic tool calling&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total tokens (what you pay)&lt;/td&gt;
&lt;td&gt;645&lt;/td&gt;
&lt;td&gt;1,392&lt;/td&gt;
&lt;td&gt;about 2.2x more (+116%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model output tokens&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;41% fewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;204&lt;/td&gt;
&lt;td&gt;both correct&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Writing the little program isn't free. The model spends tokens composing the JavaScript, and for a job this small that overhead swamps any saving. Programmatic tool calling used more than double the tokens (1,392 versus 645) to reach the same answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When tool results were big, programmatic tool calling was dramatically cheaper.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Big tool results (320 rows)&lt;/th&gt;
&lt;th&gt;Classic tool calling&lt;/th&gt;
&lt;th&gt;Programmatic tool calling&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total tokens (what you pay)&lt;/td&gt;
&lt;td&gt;19,491&lt;/td&gt;
&lt;td&gt;1,496&lt;/td&gt;
&lt;td&gt;about 92% fewer (13x fewer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model output tokens&lt;/td&gt;
&lt;td&gt;252&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;50% fewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer&lt;/td&gt;
&lt;td&gt;52,584&lt;/td&gt;
&lt;td&gt;52,584&lt;/td&gt;
&lt;td&gt;both correct&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here the classic approach paid to shuttle all 320 order rows through the model: 19,491 tokens. The programmatic approach kept those rows in the sandbox, filtered and summed them there, and returned only the total: 1,496 tokens. That's roughly a 92% cut (about 13 times fewer tokens) for the identical, correct answer.&lt;/p&gt;

&lt;p&gt;So the feature is not a free discount you flip on everywhere. &lt;strong&gt;The deciding factor is how much data your tools hand back.&lt;/strong&gt; Big, messy tool outputs that only need a small slice of the answer? Programmatic tool calling pays off, and it can pay off enormously. Tiny outputs, or a couple of quick calls? The overhead of writing the program can make it more expensive.&lt;/p&gt;

&lt;p&gt;One detail is easy to misread. In both jobs the programmatic runs took more back-and-forth trips to our app, nine versus two. Our tools live in our own code, so the sandbox has to pause and ask our app to run each call. The savings don't come from cutting those trips. They come from keeping the &lt;em&gt;results&lt;/em&gt; of those trips out of the model's reading window. Move your tools to OpenAI's side (hosted or MCP tools) and even those pauses go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can this survive your workflow?
&lt;/h2&gt;

&lt;p&gt;Here's how to translate the result into your own service. Ask one question: &lt;strong&gt;when your agent calls a tool, does it get back a lot of data it mostly throws away?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order and refund processing.&lt;/strong&gt; An agent pulls a batch of orders and totals a subset (refunds, chargebacks, a region). Classic calling pays for every row. This is the big-win case, like our 92% cut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support ticket triage.&lt;/strong&gt; If a tool returns full ticket histories and the agent needs one status or one count, the history is the expensive pile that never needs to reach the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRM and billing reads.&lt;/strong&gt; Pulling a customer's full record to answer a yes/no or a single figure is the same shape: big input, small answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal reporting and reconciliation.&lt;/strong&gt; Summing, counting, or matching across many rows is exactly what a sandbox program does cheaply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the cases where it won't help, so you don't over-adopt it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A couple of small lookups.&lt;/strong&gt; Two or three calls that each return a short value. The program-writing overhead can cost more than it saves, as our small-results run showed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model needs to read everything anyway.&lt;/strong&gt; If the task genuinely requires reasoning over the full tool output (summarizing every ticket, not counting them), keeping the data out of context defeats the point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest takeaway for a budget owner: programmatic tool calling is a targeted tool for data-heavy agent steps, not a global setting. On the right step it can cut the model bill by an order of magnitude. On the wrong step it quietly adds cost. The way to know is to measure your own tool outputs, which is exactly the kind of bounded check we run.&lt;/p&gt;

&lt;p&gt;If you're weighing this for a live service and want an inspectable, claim-bound proof on your own workload before you commit engineering time, that's what Effloow's &lt;a href="https://dev.to/proof-studio"&gt;Proof Studio&lt;/a&gt; produces. For scoping a build, see &lt;a href="https://dev.to/services"&gt;our services&lt;/a&gt;. We publish the method and the raw numbers every time, the same way we did here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, in plain words
&lt;/h2&gt;

&lt;p&gt;This is a bounded lab check, not a benchmark. We ran two synthetic tasks, once each per mode, on one model tier (Luna). Real numbers will move with the size of your tool outputs, how the model chooses to write its program, the reasoning effort you set, and whether your tools run on your side or OpenAI's. We did not measure price in dollars, speed, or the separate multi-agent beta. What we can stand behind: on these two deterministic tasks, on the live GPT-5.6 API, the direction and rough size of the effect were clear and reproducible with the script below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Effloow added
&lt;/h2&gt;

&lt;p&gt;The primary sources tell you the feature exists and how to wire it up. They don't tell you &lt;em&gt;when it's worth it&lt;/em&gt;. Our original contribution is the measured decision rule: a two-scenario, same-task comparison on the real GPT-5.6 API showing the effect flips sign with tool-output size (about 92% cheaper on big outputs, about 2.2x more expensive on tiny ones), with both answers verified correct. The full run, the exact counts, and the reproduction command are in our &lt;a href="https://dev.to/lab-runs/gpt-5-6-programmatic-tool-calling-multiagent-token-proof-2026"&gt;public lab note&lt;/a&gt;. This pairs with our earlier &lt;a href="https://dev.to/articles/claude-programmatic-tool-calling-token-proof-2026"&gt;Claude programmatic tool calling token proof&lt;/a&gt; for a cross-model view, and sits alongside our &lt;a href="https://dev.to/articles/openai-prompt-cache-retention-24h-cost-proof-2026"&gt;OpenAI prompt-cache retention cost proof&lt;/a&gt; and &lt;a href="https://dev.to/articles/openai-agents-sdk-tool-failure-recovery-proof-2026"&gt;OpenAI Agents SDK tool-failure recovery proof&lt;/a&gt; as part of a growing set of evidence-led agent-cost write-ups.&lt;/p&gt;

&lt;h2&gt;
  
  
  For your engineers
&lt;/h2&gt;

&lt;p&gt;Model and runtime: &lt;code&gt;gpt-5.6-luna&lt;/code&gt; via the OpenAI Responses API (&lt;code&gt;POST /v1/responses&lt;/code&gt;). GPT-5.6 exposes &lt;code&gt;gpt-5.6-sol&lt;/code&gt;, &lt;code&gt;gpt-5.6-terra&lt;/code&gt;, and &lt;code&gt;gpt-5.6-luna&lt;/code&gt;; the &lt;code&gt;gpt-5.6&lt;/code&gt; alias routes to Sol.&lt;/p&gt;

&lt;p&gt;Enabling programmatic tool calling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add the hosted tool &lt;code&gt;{"type": "programmatic_tool_calling"}&lt;/code&gt; to the &lt;code&gt;tools&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;On each function the program may call, set &lt;code&gt;allowed_callers: ["programmatic"]&lt;/code&gt; (values are &lt;code&gt;["direct"]&lt;/code&gt;, &lt;code&gt;["programmatic"]&lt;/code&gt;, or both) and provide an &lt;code&gt;output_schema&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Handle the new output items: a &lt;code&gt;program&lt;/code&gt; item (generated JavaScript &lt;code&gt;code&lt;/code&gt;, a &lt;code&gt;call_id&lt;/code&gt;, and a &lt;code&gt;fingerprint&lt;/code&gt;), program-issued &lt;code&gt;function_call&lt;/code&gt; items whose &lt;code&gt;caller&lt;/code&gt; field references the program's &lt;code&gt;call_id&lt;/code&gt;, and a &lt;code&gt;program_output&lt;/code&gt; item with the matching &lt;code&gt;call_id&lt;/code&gt;, a &lt;code&gt;result&lt;/code&gt; JSON string, and a &lt;code&gt;status&lt;/code&gt;. Preserve every &lt;code&gt;call_id&lt;/code&gt; and the caller linkage when you return &lt;code&gt;function_call_output&lt;/code&gt; items.&lt;/li&gt;
&lt;li&gt;Sandbox constraints per OpenAI's guide: V8 runtime with top-level &lt;code&gt;await&lt;/code&gt;, but no Node.js, no package install, no network, no general filesystem, no subprocess, no console, and no persistent state between program runs. Programs reach the outside world only through the tools you enable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measured results on this run (real API &lt;code&gt;usage&lt;/code&gt; counters):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tiny outputs: classic 2 model round-trips, 645 total tokens, 155 output tokens; programmatic 9 round-trips, 1,392 total tokens, 91 output tokens. Answer 204 in both.&lt;/li&gt;
&lt;li&gt;Bulky outputs (320 rows): classic 2 round-trips, 19,491 total tokens, 252 output tokens; programmatic 9 round-trips, 1,496 total tokens, 126 output tokens. Answer 52,584 in both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why programmatic showed more round-trips: our tools are client-side, so the hosted program pauses for our app to execute each &lt;code&gt;get_*&lt;/code&gt; call. Savings come from keeping tool &lt;em&gt;outputs&lt;/em&gt; out of model context, not from reducing app round-trips; hosted or MCP tools would remove the pauses.&lt;/p&gt;

&lt;p&gt;Reproduce: run &lt;code&gt;python3 scripts/gpt56-ptc-token-proof.py&lt;/code&gt;. It drives both scenarios in both modes, checks the budget guard before each call (&lt;code&gt;scripts/proof_budget.py&lt;/code&gt;, ledger in &lt;code&gt;data/state/openai-token-usage.json&lt;/code&gt;), records real per-turn &lt;code&gt;usage&lt;/code&gt;, and writes the artifact to &lt;code&gt;data/lab-runs/gpt-5-6-programmatic-tool-calling-multiagent-token-proof-2026.openai.json&lt;/code&gt;. Full notes and the raw counters are in the &lt;a href="https://dev.to/lab-runs/gpt-5-6-programmatic-tool-calling-multiagent-token-proof-2026"&gt;public lab note&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Primary sources: OpenAI Programmatic Tool Calling guide, OpenAI model-guidance (latest-model) doc, OpenAI's GPT-5.6 Sol announcement, and the OpenAI Responses multi-agent guide.&lt;/p&gt;

</description>
      <category>gpt56</category>
      <category>programmatictoolcalling</category>
      <category>tokencost</category>
      <category>aifinops</category>
    </item>
    <item>
      <title>A 117KB hero with a 1.2s LCP: the browser found it too late</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Tue, 14 Jul 2026 06:45:08 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/a-117kb-hero-with-a-12s-lcp-the-browser-found-it-too-late-j05</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/a-117kb-hero-with-a-12s-lcp-the-browser-found-it-too-late-j05</guid>
      <description>&lt;p&gt;I traced a single hero image. The file was a 117KB PNG, and it downloaded in 5 milliseconds on localhost. Yet the page's Largest Contentful Paint landed at 1,247 milliseconds. The image arrived in 5ms, but the largest element took 1.2 seconds to paint. So where did the other 1,242ms go?&lt;/p&gt;

&lt;p&gt;The answer is not "the image is heavy." It is a question of &lt;em&gt;when&lt;/em&gt; the browser started looking for that image. If you treat LCP purely as an image-weight problem, most of that 1.2 seconds stays out of reach forever. Today I shipped the same hero three different ways and used Chrome DevTools traces to break LCP apart, watching exactly where the bottleneck hides. Every table and log below is a real measurement from that sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  LCP is not one number — it is four segments
&lt;/h2&gt;

&lt;p&gt;Groundwork first. LCP (Largest Contentful Paint) marks the moment the largest content element in the viewport — usually a hero image or a big heading block — gets painted. Google recommends this happen within &lt;strong&gt;2.5 seconds&lt;/strong&gt; of the page starting to load (&lt;a href="https://developers.google.com/search/docs/appearance/core-web-vitals" rel="noopener noreferrer"&gt;Google Search Central, Core Web Vitals&lt;/a&gt;). That threshold is the 75th percentile of real-user field data (CrUX).&lt;/p&gt;

&lt;p&gt;Here is the part that matters. LCP looks like a single metric, but web.dev's &lt;a href="https://web.dev/articles/optimize-lcp" rel="noopener noreferrer"&gt;Optimize LCP&lt;/a&gt; guide splits it into four sub-parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTFB&lt;/strong&gt; — time to the server's first byte&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load delay&lt;/strong&gt; — time until the browser &lt;em&gt;discovers the LCP resource and starts the request&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load duration&lt;/strong&gt; — time spent actually downloading that resource&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render delay&lt;/strong&gt; — time from finishing the download to painting it on screen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I love this four-segment model for a plain reason. "LCP is slow" is a symptom, not a diagnosis. You have to see which of the four is bloated before you can prescribe anything. And most slow heroes bleed time in &lt;strong&gt;Load delay (discovery)&lt;/strong&gt;, not Load duration (download). Here is the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured: the hero shipped as a background image
&lt;/h2&gt;

&lt;p&gt;The first version is a common pattern. I laid the hero down as a CSS &lt;code&gt;background-image&lt;/code&gt; rather than an &lt;code&gt;&amp;lt;img&amp;gt;&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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600px&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("hero.png")&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cover&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="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;"style.css"&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;"hero"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test rig: a local threaded HTTP server, &lt;code&gt;Cache-Control: no-store&lt;/code&gt; on every response (so nothing gets reused), and a 1-second delay bolted onto the stylesheet. That delay simulates a real-world slow render-blocking CSS file. I captured a Chrome DevTools &lt;code&gt;performance&lt;/code&gt; trace with a reload. The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LCP: 1247 ms
  TTFB:          8 ms
  Load delay: 1184 ms   ← here
  Load duration: 5 ms
  Render delay:  51 ms
DevTools insight: "LCP request discovery" flagged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load delay: 1,184ms. The image download took 5ms, but the browser spent 1.2 seconds just &lt;strong&gt;discovering&lt;/strong&gt; it. Why? Because the hero URL is buried inside CSS.&lt;/p&gt;

&lt;p&gt;One key concept here. Browsers run a &lt;strong&gt;preload scanner&lt;/strong&gt;. When the HTML response bytes arrive, before the main parser even runs, this scanner skims the raw HTML and finds resources like &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;link href&amp;gt;&lt;/code&gt; so it can kick off those requests early. The catch: the scanner only reads HTML. &lt;strong&gt;A &lt;code&gt;background-image&lt;/code&gt; URL sitting in CSS is invisible to it&lt;/strong&gt; (&lt;a href="https://web.dev/preload-scanner/" rel="noopener noreferrer"&gt;web.dev, Don't fight the browser preload scanner&lt;/a&gt;). So a background image cannot even be requested until the CSS downloads and parses. My CSS carried a 1-second delay, so the hero got discovered exactly that late. Chrome itself called this out as an "LCP request discovery" insight (&lt;a href="https://developer.chrome.com/docs/performance/insights/lcp-discovery" rel="noopener noreferrer"&gt;LCP discovery, Chrome for Developers&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;This shares a root with the story about &lt;a href="https://dev.to/en/blog/en/ai-crawlers-dont-render-javascript-csr-2026"&gt;AI crawlers not running your JavaScript and therefore never seeing your content&lt;/a&gt;. Where and how a resource is placed decides when — or whether — it gets processed at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  I added fetchpriority and preload — and LCP barely moved
&lt;/h2&gt;

&lt;p&gt;Second version. I switched the hero to a real &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, added &lt;code&gt;fetchpriority="high"&lt;/code&gt;, and put a preload hint 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="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"image"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"hero.png"&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="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"hero.png"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Product launch hero"&lt;/span&gt;
     &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt; &lt;span class="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;&lt;code&gt;fetchpriority="high"&lt;/code&gt; is a hint that tells the browser to fetch this resource ahead of others, at high priority (&lt;a href="https://web.dev/articles/fetch-priority" rel="noopener noreferrer"&gt;web.dev, Fetch Priority API&lt;/a&gt;). Why does it help? Before layout, the browser has no idea where an image will land on screen, so it assigns most images a low initial priority. The hero and a decorative footer icon get the same early treatment. &lt;code&gt;fetchpriority="high"&lt;/code&gt; pulls exactly one of them — the hero — up by hand. Because it is now an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, the URL lives in the HTML, and the preload scanner spots it immediately. The measurement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LCP: 1226 ms
  TTFB:          3 ms
  Load delay:   37 ms   ← plunged from 1184 to 37
  Load duration: 2 ms
  Render delay: 1185 ms  ← this one swelled instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load delay collapsed from 1,184ms to 37ms. The discovery problem is completely solved. The image is fully received by 42ms into the navigation. And yet &lt;strong&gt;LCP is still 1226ms&lt;/strong&gt;, basically unchanged. I will be honest: the first time I saw this I stopped for a second. If you speed up discovery 30x and the headline metric does not move, you missed something.&lt;/p&gt;

&lt;p&gt;The bottleneck had moved. Now Render delay eats 1,185ms. The image is ready at 42ms, but &lt;strong&gt;the browser cannot paint it&lt;/strong&gt; — the render-blocking stylesheet (my 1-second file) has not arrived. Chrome paints nothing until the CSS lands and it can produce a first paint. You are holding the image, but you cannot pick up the brush.&lt;/p&gt;

&lt;p&gt;This is the whole point I want to land. &lt;strong&gt;fetchpriority and preload are necessary, not sufficient.&lt;/strong&gt; They erase the discovery bottleneck. But if any of LCP's other three segments is swollen, pulling discovery earlier only moves the number by that much. Spray fetchpriority everywhere without reading the four segments, and it is easy to fool yourself into thinking things got better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kill render-blocking too, and LCP goes 1247 → 109ms
&lt;/h2&gt;

&lt;p&gt;Third version. I kept the &lt;code&gt;&amp;lt;img fetchpriority&amp;gt;&lt;/code&gt; from before and removed the render-blocking CSS. I inlined only the critical CSS the hero needs to render into a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block, and loaded the rest of the stylesheet without blocking paint.&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;style&amp;gt;&lt;/span&gt;
  &lt;span class="nc"&gt;.herowrap&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;600px&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="nc"&gt;.herowrap&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;600px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nl"&gt;object-fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&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;"style.css"&lt;/span&gt;
      &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"print"&lt;/span&gt; &lt;span class="na"&gt;onload=&lt;/span&gt;&lt;span class="s"&gt;"this.media='all'"&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;Setting &lt;code&gt;media="print"&lt;/code&gt; keeps it from blocking screen render, then &lt;code&gt;onload&lt;/code&gt; flips it to &lt;code&gt;all&lt;/code&gt; so it actually applies. It is a well-worn non-blocking CSS pattern. The measurement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LCP: 109 ms
  TTFB:          5 ms
  Load delay:   43 ms
  Load duration: 1 ms
  Render delay:  60 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 1,247ms to 109ms. All three segments settled into double digits. Discovery is fast (43ms), and nothing is blocking the paint (Render delay 60ms). Line the three versions up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hero delivery&lt;/th&gt;
&lt;th&gt;Load delay (discovery)&lt;/th&gt;
&lt;th&gt;Render delay&lt;/th&gt;
&lt;th&gt;LCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSS &lt;code&gt;background-image&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,184 ms&lt;/td&gt;
&lt;td&gt;51 ms&lt;/td&gt;
&lt;td&gt;1,247 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;img fetchpriority&amp;gt;&lt;/code&gt; + preload&lt;/td&gt;
&lt;td&gt;37 ms&lt;/td&gt;
&lt;td&gt;1,185 ms&lt;/td&gt;
&lt;td&gt;1,226 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ inline critical CSS (non-blocking)&lt;/td&gt;
&lt;td&gt;43 ms&lt;/td&gt;
&lt;td&gt;60 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;109 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fix one number and the bottleneck ran sideways. Fix both bottlenecks and the metric finally caved. This table is the four-segment model earning its keep.&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/..%2F..%2F..%2Fassets%2Fblog%2Flcp-image-preload-scanner-fetchpriority-2026%2Flcp-breakdown.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/..%2F..%2F..%2Fassets%2Fblog%2Flcp-image-preload-scanner-fetchpriority-2026%2Flcp-breakdown.png" alt="Stacked bar chart of LCP breakdown per hero delivery strategy, measured with Chrome DevTools traces. background-image shows Load delay 1184ms, fetchpriority+preload shows Render delay 1185ms, inline critical CSS reaches LCP 109ms" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So here is what to do today (a checklist)
&lt;/h2&gt;

&lt;p&gt;Translated into practice, the experiment says:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ship the hero as an HTML &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, not a CSS background.&lt;/strong&gt; The preload scanner has to see it to discover it early. If the design truly demands a background, front-run discovery yourself with &lt;code&gt;&amp;lt;link rel="preload" as="image" href="..." fetchpriority="high"&amp;gt;&lt;/code&gt; (&lt;a href="https://web.dev/preload-scanner/" rel="noopener noreferrer"&gt;web.dev preload-scanner&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put &lt;code&gt;fetchpriority="high"&lt;/code&gt; on the LCP image.&lt;/strong&gt; Browsers often assign images a low initial priority. Raise the hero, and only the hero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never put &lt;code&gt;loading="lazy"&lt;/code&gt; on the LCP image.&lt;/strong&gt; Great for below-the-fold images, self-inflicted harm on a top-of-viewport hero — you are delaying your own discovery (&lt;a href="https://web.dev/blog/common-misconceptions-lcp" rel="noopener noreferrer"&gt;web.dev, LCP misconceptions&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline critical CSS and make the rest non-blocking.&lt;/strong&gt; Receiving the image early does nothing if render-blocking CSS still gates the paint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always set &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; (or &lt;code&gt;aspect-ratio&lt;/code&gt;).&lt;/strong&gt; It prevents layout shift (CLS). Across all three versions above, CLS was 0.00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Above all, read the LCP breakdown first.&lt;/strong&gt; The DevTools Performance panel hands you TTFB / Load delay / Load duration / Render delay directly. Attack the biggest segment. I dug into this measure-then-fix workflow in the post on &lt;a href="https://dev.to/en/blog/en/a11y-lighthouse-audit-fix-2026"&gt;reading Lighthouse to catch and fix accessibility violations&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Honest limits — do not misread these numbers
&lt;/h2&gt;

&lt;p&gt;My numbers are &lt;strong&gt;lab figures&lt;/strong&gt;. Local server, no network throttling, CPU at 1x. Even the 1-second stylesheet delay is a value I injected on purpose to make the effect visible. So do not paste the "1247 → 109" ratio onto your own site. These numbers are a &lt;strong&gt;demonstration of the mechanism&lt;/strong&gt;, not the absolute gain you should expect. What Google actually uses for ranking is CrUX field data from real users, and your gain depends on your page's real critical path.&lt;/p&gt;

&lt;p&gt;The second limit matters more. &lt;strong&gt;Landing LCP under 2.5s does not guarantee a ranking bump.&lt;/strong&gt; Google states plainly that "there's no single ranking signal," and that good page experience "doesn't override having great, relevant content" (&lt;a href="https://developers.google.com/search/docs/appearance/core-web-vitals" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;). Core Web Vitals is one signal among many. I think the honest pitch for LCP work is "a UX job that reduces bounce," not "a ranking lever." A fast page is good because people do not leave, not because speed buys you a top spot.&lt;/p&gt;

&lt;p&gt;Let me also log, honestly, the two traps that fooled me while reproducing this. I started with a single-threaded Python server, and the &lt;code&gt;fetchpriority&lt;/code&gt; version discovered the image early yet LCP would not drop. While the server sat blocked serving the 1-second CSS, the early-discovered image request queued up behind it. A real HTTP/2 origin would have multiplexed both down at once; a server artifact was masking the real win. Only after switching to a threaded server did the numbers get honest. The second trap was caching. Without &lt;code&gt;no-store&lt;/code&gt;, the browser pulled the previous run's CSS from disk cache, the 1-second delay vanished, and the entire "before" penalty evaporated. A good reminder to distrust the test rig before you trust the measurement.&lt;/p&gt;

&lt;p&gt;Third: preload backfires when overused. Hand high priority to everything via preload and you starve the resources that actually matter, and depending on conditions you can even double-download an image. Spend preload sparingly, on the &lt;strong&gt;single LCP element&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One sentence sums it up. When LCP is slow, do not suspect image weight first — open the four segments and see when the browser discovers the element and when it paints it. The bottleneck usually hides in discovery and render-blocking, not download. I reached the same conclusion while auditing a multilingual blog and stripping out render-blocking resources in &lt;a href="https://dev.to/en/blog/en/multilingual-blog-technical-audit-campaign-2026"&gt;this technical SEO audit log&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you want structured data emitted reliably server-side, or an existing site's Core Web Vitals, accessibility, and crawler handling checked with real measurements, I take on consulting and implementation work personally. Reach me through the contact link on my profile.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>corewebvitals</category>
      <category>lcp</category>
      <category>webperf</category>
      <category>rendering</category>
    </item>
    <item>
      <title>Catch Broken JSON-LD in CI Before It Ships</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Mon, 13 Jul 2026 06:38:43 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/catch-broken-json-ld-in-ci-before-it-ships-45pg</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/catch-broken-json-ld-in-ci-before-it-ships-45pg</guid>
      <description>&lt;p&gt;The structured-data check in your deploy pipeline is green. That green proves exactly one thing: your JSON-LD is &lt;strong&gt;well-formed&lt;/strong&gt;. It does not prove Google can read a single field inside it. These are two different questions, and most teams treat them as one.&lt;/p&gt;

&lt;p&gt;It clicked for me the day I watched a &lt;code&gt;@type&lt;/code&gt; written as lowercase &lt;code&gt;article&lt;/code&gt; sail through a parser without a peep. To a JSON-LD processor it's perfectly valid. To Google it's an unknown type, silently ignored. Nothing in between. No warning, no error, no red. You find out six months later, digging through Search Console to figure out why the rich result never showed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation has two layers
&lt;/h2&gt;

&lt;p&gt;When people say they "validate" structured data, they're usually pointing at two different things.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;syntactic validation&lt;/strong&gt;. Does this JSON-LD parse? Are the braces balanced, is there a &lt;code&gt;@context&lt;/code&gt;, can a JSON-LD 1.1 processor expand it into a graph? A library like &lt;code&gt;jsonld&lt;/code&gt; nails this every time.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;schema-semantic validation&lt;/strong&gt;. Is the type name a real schema.org term, in the right casing? Are the property names free of typos? Is the date ISO 8601? Are URL fields absolute? Does the node carry the properties Google recommends for that type? The parser &lt;strong&gt;won't tell you any of that&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the trap: the second can fail while the first passes without blinking. And Google's own validators, the Rich Results Test and the Schema Markup Validator (validator.schema.org), are both &lt;strong&gt;manual, browser-based tools&lt;/strong&gt; where you paste a URL or a blob of code. Neither lives in your build. So unless a human opens one by hand, broken schema flows straight to production.&lt;/p&gt;

&lt;p&gt;If you've already decided &lt;a href="https://dev.to/en/blog/en/structured-data-syntax-comparison-jsonld-microdata-rdfa-2026"&gt;which syntax to use among JSON-LD, Microdata, and RDFa&lt;/a&gt;, the next question is this: who checks that the markup you write in that syntax is actually correct, on every commit?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this gap costs more now
&lt;/h2&gt;

&lt;p&gt;There was a time when silently broken structured data cost you, at most, a rich-result snippet. Not anymore. Search is shifting, and the crawlers that build AI overviews and generative answers lean harder on structured data to work out what a page means. Many of those crawlers &lt;a href="https://dev.to/en/blog/en/ai-crawlers-dont-render-javascript-csr-2026"&gt;don't run your JavaScript; they grab the raw HTML and leave&lt;/a&gt;. The JSON-LD your server emits is nearly all they see.&lt;/p&gt;

&lt;p&gt;So what happens when that JSON-LD carries a lowercase &lt;code&gt;article&lt;/code&gt;? To a human the page looks fine, and the parser waves it through, but to the machine reading it, that's an unidentified node with no author and no publish date. The price of a single slip has grown from "you miss a snippet" to "an AI misreads your page." Catching it before deploy pays off more than it used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the parser can't catch the typo
&lt;/h2&gt;

&lt;p&gt;I didn't want to just assert this, so I reproduced it in a sandbox. Node v22, &lt;code&gt;jsonld&lt;/code&gt; 8.x. I built a &lt;code&gt;broken.json&lt;/code&gt; seeded with five everyday mistakes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@graph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"article"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Broken sample"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"datePublished"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"07/13/2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"authour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Kim Jangwook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hero.png"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BreadcrumbList"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"itemListElement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ListItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Blog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/blog"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lowercase &lt;code&gt;article&lt;/code&gt;, the typo &lt;code&gt;authour&lt;/code&gt;, a US-style date &lt;code&gt;07/13/2026&lt;/code&gt;, a relative &lt;code&gt;hero.png&lt;/code&gt;, and a &lt;code&gt;ListItem&lt;/code&gt; missing &lt;code&gt;position&lt;/code&gt;. Run that through &lt;code&gt;jsonld.expand()&lt;/code&gt; and you can see which IRI the processor resolves each term to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node expand-demo.mjs

===== broken.json — jsonld.expand() =====
resolved @type IRIs : http://schema.org/article, http://schema.org/BreadcrumbList, ...
resolved term IRIs  : http://schema.org/article, http://schema.org/authour,
                      http://schema.org/datePublished, http://schema.org/headline, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the whole point. &lt;code&gt;article&lt;/code&gt; expands to &lt;code&gt;http://schema.org/article&lt;/code&gt;, and &lt;code&gt;authour&lt;/code&gt; expands to &lt;code&gt;http://schema.org/authour&lt;/code&gt;, &lt;strong&gt;cleanly&lt;/strong&gt;. No error. No warning. Nothing dropped.&lt;/p&gt;

&lt;p&gt;The reason is that schema.org's hosted JSON-LD context sets &lt;code&gt;@vocab&lt;/code&gt; to &lt;code&gt;https://schema.org/&lt;/code&gt;. When &lt;code&gt;@vocab&lt;/code&gt; is present, the processor &lt;strong&gt;just concatenates&lt;/strong&gt; any undefined string onto that prefix. It never checks whether a property called &lt;code&gt;authour&lt;/code&gt; exists in schema.org. It manufactures a nonexistent IRI, and that is entirely legal JSON-LD. The parser inspects syntax, not vocabulary.&lt;/p&gt;

&lt;p&gt;That's where the gap between "valid JSON-LD" and "readable by Google" opens up. The same gap runs through &lt;a href="https://dev.to/en/blog/en/json-ld-graph-entity-linking-2026"&gt;wiring scattered blocks into one @graph&lt;/a&gt;: before you talk about connecting nodes, each node has to be written with a valid type and valid properties in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 60-line schema-aware validator
&lt;/h2&gt;

&lt;p&gt;If the parser won't catch it, bolt on a check that knows the schema. It doesn't need to be elaborate. A slice of vocabulary for the types you care about, plus five rules.&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;VOCAB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;props&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;headline&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;datePublished&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;dateModified&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;author&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;image&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;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// Google lists NO required properties for Article (only recommended).&lt;/span&gt;
    &lt;span class="c1"&gt;// Enforcing headline is our team policy, not a Google rule.&lt;/span&gt;
    &lt;span class="na"&gt;recommended&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;headline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;urlProps&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;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;dateProps&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;datePublished&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;dateModified&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;BreadcrumbList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;props&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;itemListElement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;required&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;itemListElement&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;ListItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;props&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;position&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;name&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;item&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;required&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;position&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;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;urlProps&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;item&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VOCAB&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;ISO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}(&lt;/span&gt;&lt;span class="sr"&gt;T&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2}(&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2})?([&lt;/span&gt;&lt;span class="sr"&gt;+-&lt;/span&gt;&lt;span class="se"&gt;]\d{2}&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;|Z&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ABS&lt;/span&gt; &lt;span class="o"&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;:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errors&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;KNOWN&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="nx"&gt;t&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;near&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;KNOWN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;near&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`@type "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is wrong casing → "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;near&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;near&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VOCAB&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&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;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&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;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;props&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="nx"&gt;key&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;near&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: not a valid property&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;near&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` → "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;near&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="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&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;r&lt;/span&gt; &lt;span class="k"&gt;of &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt; &lt;span class="o"&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: missing required field "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;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;d&lt;/span&gt; &lt;span class="k"&gt;of &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dateProps&lt;/span&gt; &lt;span class="o"&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;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ISO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: not ISO 8601`&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;u&lt;/span&gt; &lt;span class="k"&gt;of &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urlProps&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;ABS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: not an absolute URL`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// recurse into nested nodes and itemListElement&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;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&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="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;v&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;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;checkNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errors&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;Notice that when it hits a casing error, it doesn't throw and stop. It &lt;strong&gt;recovers to the correct type and keeps checking&lt;/strong&gt;. That way you see both that &lt;code&gt;article&lt;/code&gt; is wrong and that the same node has an &lt;code&gt;authour&lt;/code&gt;, a bad date, and a relative URL, all in one pass. My first cut skipped that recovery, reported the one type error, and missed the other four. CI has to show everything at once, or you burn a round trip per fix.&lt;/p&gt;

&lt;p&gt;Look closely at Article being tagged &lt;code&gt;recommended&lt;/code&gt; rather than &lt;code&gt;required&lt;/code&gt;. Per Google's own docs, &lt;strong&gt;Article has no required properties&lt;/strong&gt;. &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;datePublished&lt;/code&gt;, &lt;code&gt;dateModified&lt;/code&gt;, &lt;code&gt;headline&lt;/code&gt;, and &lt;code&gt;image&lt;/code&gt; are all merely recommended. So enforcing headline is a call your team makes, not a rule Google imposes. That's exactly what a validator is for: encoding "the floor our org sets on top of Google's recommendations" as code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the run actually produced
&lt;/h2&gt;

&lt;p&gt;I fed &lt;code&gt;good.json&lt;/code&gt; (a clean Article plus a two-step BreadcrumbList) and &lt;code&gt;broken.json&lt;/code&gt; into the same validator.&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/..%2F..%2F..%2Fassets%2Fblog%2Fvalidate-structured-data-ci-jsonld-2026%2Fci-run-log.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/..%2F..%2F..%2Fassets%2Fblog%2Fvalidate-structured-data-ci-jsonld-2026%2Fci-run-log.png" alt="Real CI run log of the structured-data validator. good.json returns PASS with 0 problems; broken.json returns FAIL with 5 problems, catching the casing error, the property typo, the bad date, the relative URL, and the missing required field, then exits 1 to block the build" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== good.json =====
PASS — 0 problems

===== broken.json =====
FAIL — 5 problems
  x @type "article" is wrong casing → "Article"
  x Article.authour: not a valid property → "author"
  x Article.datePublished: "07/13/2026" is not ISO 8601
  x Article.image: "hero.png" must be an absolute URL
  x ListItem: missing Google-required field "position"
process exit code = 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All five caught, and the process ended on &lt;code&gt;broken.json&lt;/code&gt; with &lt;strong&gt;exit 1&lt;/strong&gt;. That exit code is the whole game. &lt;code&gt;good.json&lt;/code&gt; exits 0. With that one line, CI blocks the build with no extra configuration.&lt;/p&gt;

&lt;p&gt;Notice only the &lt;code&gt;ListItem&lt;/code&gt; missing &lt;code&gt;position&lt;/code&gt; is labeled "Google-required." That's accurate. A BreadcrumbList requires at least two ListItems, and each ListItem genuinely requires &lt;code&gt;position&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt; (official). None of the four Article errors carry a "required" tag. The validator is speaking precisely, keeping Google's rules and your team's policy on separate lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into a CI gate
&lt;/h2&gt;

&lt;p&gt;Since the exit code is already 1, the rest is plumbing. One script in &lt;code&gt;package.json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"validate:schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node scripts/validate-schema.mjs"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one step in a GitHub Actions job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate structured data&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run validate:schema&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the validator fails, the job fails, and a PR carrying broken schema doesn't merge. To sweep the whole site, scrape every &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block out of the built HTML and pipe each into the same &lt;code&gt;checkNode&lt;/code&gt;. Same principle. It's the same skeleton as &lt;a href="https://dev.to/en/blog/en/axe-core-ci-a11y-jsdom-vs-browser-2026"&gt;putting accessibility checks in CI&lt;/a&gt;: take the thing a human used to eyeball by hand and turn it into a deterministic gate that goes red on failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this validator can't do
&lt;/h2&gt;

&lt;p&gt;The post is only honest if I draw the line clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is not a replacement for the Rich Results Test.&lt;/strong&gt; It knows a hand-picked slice of vocabulary (Article, BreadcrumbList, ListItem, Person). For real coverage you'd generate the type and property lists from schema.org's public dump and fill &lt;code&gt;VOCAB&lt;/code&gt; from that. What you saw here is a proof of concept, not a finished product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passing validation does not guarantee a rich result.&lt;/strong&gt; That's not my opinion, it's Google's official stance. The General Structured Data Guidelines say it plainly: using structured data "enables a feature to be present, it does not guarantee that it will be present." Even with flawless markup, Google's algorithm may decide, based on the user, device, or location, that a plain text result is better. The same guidelines state that structured data "by itself is not a generic ranking factor." What the validator passes is "the shape is correct," not "a rich result will appear" and certainly not "your ranking will rise."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expansion only sees syntax.&lt;/strong&gt; As shown above, &lt;code&gt;@vocab&lt;/code&gt; expands even a typo into a valid IRI. So don't mistake a successful expansion for validation. The two layers don't substitute for each other. Leave syntax to the parser and meaning to a schema-aware check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Paste your build's JSON-LD into the Rich Results Test once by hand to set a baseline. Then move that check into code.&lt;/li&gt;
&lt;li&gt;Start &lt;code&gt;VOCAB&lt;/code&gt; with the types you actually use most (usually Article/BlogPosting, BreadcrumbList, Organization, WebSite). Don't try to fill everything; start with what you get wrong.&lt;/li&gt;
&lt;li&gt;Always check the four things a parser will never flag: casing, property typos, date format, and relative URLs.&lt;/li&gt;
&lt;li&gt;Label Google's "required" and your team's "policy" separately in code. Later, nobody wonders why a field is enforced.&lt;/li&gt;
&lt;li&gt;Bind exit code 1 to the CI step. A check that only prints a report and passes is a check nobody reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want structured data emitted reliably server-side, or an existing site's schema, accessibility, and crawler handling reviewed at the pipeline level, I take on consulting and implementation work personally. Reach me through the contact link on my profile. Looking behind the green check is the work I do.&lt;/p&gt;

</description>
      <category>structureddata</category>
      <category>jsonld</category>
      <category>ci</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
