<?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: Ahmed Mahmoud</title>
    <description>The latest articles on DEV Community by Ahmed Mahmoud (@ahmed_mahmoud360).</description>
    <link>https://dev.to/ahmed_mahmoud360</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%2F656404%2F01b9474b-ca4f-4578-a15e-36a90ad96c82.jpeg</url>
      <title>DEV Community: Ahmed Mahmoud</title>
      <link>https://dev.to/ahmed_mahmoud360</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmed_mahmoud360"/>
    <language>en</language>
    <item>
      <title>Why My Fonts Caused Layout Shift: Field Notes on next/font, size-adjust, and Arabic Fallback Metrics</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:00:13 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/why-my-fonts-caused-layout-shift-field-notes-on-nextfont-size-adjust-and-arabic-fallback-metrics-2288</link>
      <guid>https://dev.to/ahmed_mahmoud360/why-my-fonts-caused-layout-shift-field-notes-on-nextfont-size-adjust-and-arabic-fallback-metrics-2288</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Font-driven layout shift is a metrics mismatch, not a download-speed problem — the fallback font the browser paints first occupies a different box than the webfont that replaces it. &lt;code&gt;next/font&lt;/code&gt; removes that mismatch automatically for Latin text by generating a &lt;code&gt;size-adjust&lt;/code&gt;ed fallback &lt;code&gt;@font-face&lt;/code&gt;, and it does not remove it for Arabic, where I had to compute the override descriptors myself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I run a bilingual site with English and Arabic routes. The English routes were visually stable. The Arabic routes jumped on every cold load, and the jump moved the whole right-to-left column, not one line. These are my notes on what was actually happening and what fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Font layout shift is a metrics mismatch between the fallback font and the webfont, not a slow-download problem — a faster CDN does not remove it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next/font&lt;/code&gt; self-hosts font files, preloads them per route, and with &lt;code&gt;adjustFontFallback&lt;/code&gt; generates a fallback &lt;code&gt;@font-face&lt;/code&gt; carrying &lt;code&gt;size-adjust&lt;/code&gt;, &lt;code&gt;ascent-override&lt;/code&gt;, &lt;code&gt;descent-override&lt;/code&gt;, and &lt;code&gt;line-gap-override&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That generated fallback is derived from a Latin system font, so Arabic, Cyrillic, and CJK text still shifts and needs a hand-written fallback &lt;code&gt;@font-face&lt;/code&gt; scoped with &lt;code&gt;unicode-range&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;font-display: optional&lt;/code&gt; removes swap-driven shift entirely: it has a block period of roughly 100 ms and a swap period of zero.&lt;/li&gt;
&lt;li&gt;Reproduce a cold load with cache disabled and throttling on — a warm font cache hides this bug on every local test while real users keep hitting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually causes font-related layout shift?
&lt;/h2&gt;

&lt;p&gt;Font layout shift happens when the fallback font the browser paints during the download occupies a different amount of space than the webfont that replaces it. Cumulative Layout Shift (CLS) — the Core Web Vitals metric that sums unexpected movement of visible elements, where 0.1 or less is the &lt;em&gt;good&lt;/em&gt; threshold — records that swap as a shift whenever it moves surrounding content. Download speed does not help: on a cold cache the browser still paints at least one frame with the fallback.&lt;/p&gt;

&lt;p&gt;Two numbers decide how big the jump is. The first is average character width: if the fallback is wider, text wraps onto more lines and every block below it moves down. The second is the ascent-to-descent ratio, which sets the height of each line box. A fallback that is only four percent wider than the webfont can still push a paragraph onto an extra line and move a hero image.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;font-display: swap&lt;/code&gt; — the value &lt;code&gt;next/font&lt;/code&gt; uses by default — paints fallback text immediately and swaps in the webfont whenever it arrives. Swap guarantees the text is readable, and it guarantees a shift unless the two fonts have matching metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does next/font actually do for me, and what doesn't it?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next/font&lt;/code&gt; — the font loader built into Next.js since version 13.2, imported as &lt;code&gt;next/font/google&lt;/code&gt; or &lt;code&gt;next/font/local&lt;/code&gt; — does four things at build time. It downloads and self-hosts the font files from your own origin, so there is no runtime request to &lt;code&gt;fonts.gstatic.com&lt;/code&gt;. It emits a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; for the font files a route uses. It sets &lt;code&gt;font-display: swap&lt;/code&gt;. And with &lt;code&gt;adjustFontFallback&lt;/code&gt;, which defaults to &lt;code&gt;true&lt;/code&gt;, it generates a second &lt;code&gt;@font-face&lt;/code&gt; for a local fallback carrying &lt;code&gt;size-adjust&lt;/code&gt;, &lt;code&gt;ascent-override&lt;/code&gt;, &lt;code&gt;descent-override&lt;/code&gt;, and &lt;code&gt;line-gap-override&lt;/code&gt; derived from the webfont's own metrics.&lt;/p&gt;

&lt;p&gt;Three things &lt;code&gt;next/font&lt;/code&gt; does not do. It does not pick a sensible fallback for a non-Latin script, because the generated fallback is based on Arial or Times New Roman. It does not preload a font imported in a component the route never renders. And it does not protect you when your CSS re-declares &lt;code&gt;font-family: Cairo, sans-serif&lt;/code&gt; by family name, which drops the generated fallback out of the chain entirely. That last one is the mistake I actually shipped: my Tailwind theme pointed &lt;code&gt;--font-sans&lt;/code&gt; at the family name instead of the CSS variable the loader returns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/fonts.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Inter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Cairo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/font/google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;subsets&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;latin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;variable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--font-sans&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;adjustFontFallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// default — emits the size-adjusted fallback @font-face&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cairo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cairo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;subsets&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;arabic&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;latin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;variable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--font-sans-ar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Use the variable, never the family name:&lt;/span&gt;
&lt;span class="c1"&gt;// font-family: var(--font-sans), system-ui, sans-serif;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do size-adjust and the override descriptors remove the shift?
&lt;/h2&gt;

&lt;p&gt;The four descriptors rescale the fallback font so it occupies the same box as the webfont &lt;em&gt;before&lt;/em&gt; the swap happens. &lt;code&gt;size-adjust&lt;/code&gt; is a &lt;code&gt;@font-face&lt;/code&gt; descriptor that multiplies every glyph's advance width and the effective em size by a percentage. &lt;code&gt;ascent-override&lt;/code&gt;, &lt;code&gt;descent-override&lt;/code&gt;, and &lt;code&gt;line-gap-override&lt;/code&gt; replace the font's vertical metrics so line box height stops depending on which font is currently active. Chrome and Firefox have supported all four since 2021, and Safari added them in version 17. Browsers that do not understand the descriptors ignore them, so this is progressive enhancement rather than a compatibility risk.&lt;/p&gt;

&lt;p&gt;The ratio Next.js uses — and &lt;code&gt;fontaine&lt;/code&gt;, the framework-agnostic plugin that does the same job for Vite and Nuxt — is straightforward: normalise each font's average character width by its units-per-em, divide the webfont's value by the fallback's, then express the webfont's vertical metrics relative to that adjusted em.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/font-metrics.ts — run once, paste the output into CSS&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fontkit&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fontkit&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;web&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fontkit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./public/fonts/Cairo-Variable.ttf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// macOS ships Geeza Pro inside a .ttc collection: pass the PostScript name&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fontkit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/System/Library/Fonts/Supplemental/GeezaPro.ttc&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;GeezaPro&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;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OS/2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;xAvgCharWidth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitsPerEm&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;sizeAdjust&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fallback&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;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;`&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sizeAdjust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sizeAdjust&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;ascentOverride&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ascent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitsPerEm&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sizeAdjust&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;descentOverride&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;descent&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;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitsPerEm&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sizeAdjust&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;lineGapOverride&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lineGap&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitsPerEm&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sizeAdjust&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When should I use font-display: optional instead of swap?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;font-display: optional&lt;/code&gt; when CLS on a route matters more than showing the brand font to first-time visitors. &lt;code&gt;optional&lt;/code&gt; gives the font a block period of roughly 100 ms and a swap period of zero: if the file is not ready in time, the browser keeps the fallback for that entire page view and uses the webfont on the next navigation, once it sits in the HTTP cache. No swap means no swap-driven shift, whatever the metrics mismatch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;font-display&lt;/th&gt;
&lt;th&gt;Block period&lt;/th&gt;
&lt;th&gt;Swap period&lt;/th&gt;
&lt;th&gt;Layout shift risk&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;swap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~100 ms&lt;/td&gt;
&lt;td&gt;Infinite&lt;/td&gt;
&lt;td&gt;High unless fallback metrics are matched&lt;/td&gt;
&lt;td&gt;Body text with a size-adjusted fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;optional&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~100 ms&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;td&gt;None from swapping&lt;/td&gt;
&lt;td&gt;Display faces, CLS-sensitive routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fallback&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~100 ms&lt;/td&gt;
&lt;td&gt;~3 s&lt;/td&gt;
&lt;td&gt;Medium — swap only inside the window&lt;/td&gt;
&lt;td&gt;Compromise between the two above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~3 s&lt;/td&gt;
&lt;td&gt;Infinite&lt;/td&gt;
&lt;td&gt;Low shift, but invisible text hurts LCP&lt;/td&gt;
&lt;td&gt;Icon fonts where a wrong glyph is worse than no glyph&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I kept &lt;code&gt;swap&lt;/code&gt; for body text once the fallback metrics matched, and moved the display face to &lt;code&gt;optional&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my Arabic text still shift after all that?
&lt;/h2&gt;

&lt;p&gt;Arabic kept shifting because &lt;code&gt;adjustFontFallback&lt;/code&gt; generates its override against a Latin system font, while the font that actually renders Arabic glyphs is a different file with different metrics: Geeza Pro on macOS and iOS, Noto Naskh Arabic or Noto Sans Arabic on Android, Segoe UI or Tahoma on Windows. Arabic system fallbacks also tend to have taller line boxes than Latin ones, so the mismatch appears as vertical movement down an entire right-to-left column rather than one rewrapped line.&lt;/p&gt;

&lt;p&gt;The fix is a hand-written fallback &lt;code&gt;@font-face&lt;/code&gt; per script, scoped with &lt;code&gt;unicode-range&lt;/code&gt; so it only claims Arabic code points, with overrides computed against the real system font instead of Arial.&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="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'Cairo Fallback AR'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;'Geeza Pro'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;'Noto Naskh Arabic'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;'Tahoma'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c"&gt;/* numbers below are illustrative — generate your own with the script above */&lt;/span&gt;
  &lt;span class="py"&gt;size-adjust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;96.40%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;ascent-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100.70%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;descent-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;34.60%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;line-gap-override&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="py"&gt;unicode-range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="m"&gt;0600-06&lt;/span&gt;&lt;span class="n"&gt;FF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="m"&gt;0750-077&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="m"&gt;08&lt;/span&gt;&lt;span class="n"&gt;A0-08FF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="n"&gt;FB50-FDFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="n"&gt;FE70-FEFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;'rtl'&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-sans-ar&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;'Cairo Fallback AR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details cost me time here. &lt;code&gt;fontkit.openSync()&lt;/code&gt; on a macOS &lt;code&gt;.ttc&lt;/code&gt; collection returns the collection rather than a face, so the metrics read back as &lt;code&gt;undefined&lt;/code&gt; until you pass the PostScript name as the second argument. And the &lt;code&gt;unicode-range&lt;/code&gt; is not decoration: without it the Arabic fallback also claims Latin code points, and English headings inherit an adjustment computed for the wrong script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which font files actually get preloaded?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next/font/google&lt;/code&gt; preloads only the subsets you declare, and only on routes where the font module is imported. That makes &lt;code&gt;subsets&lt;/code&gt; a correctness setting rather than an optimisation — omit it and the build fails with a missing-subsets error unless a default is configured in &lt;code&gt;next.config&lt;/code&gt;. It also means importing an Arabic font in the root layout preloads Arabic font files on every English route.&lt;/p&gt;

&lt;p&gt;Splitting the font imports per locale segment removed that waste for me: the shared root layout owns the Latin font, and the right-to-left layout segment imports the Arabic one. For weights that appear on a handful of pages, &lt;code&gt;preload: false&lt;/code&gt; keeps the &lt;code&gt;@font-face&lt;/code&gt; without spending early bandwidth on it. A variable font is usually the better default anyway — one file covering the whole weight axis beats four static weights, each with its own preload tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I verify the fix instead of guessing?
&lt;/h2&gt;

&lt;p&gt;Reproduce a cold load first: open DevTools, check &lt;em&gt;Disable cache&lt;/em&gt;, and throttle the network. With a warm font cache the file resolves from disk before first paint and the shift disappears, which is precisely why this class of bug survives local testing and only shows up in field data.&lt;/p&gt;

&lt;p&gt;Then attribute the shift instead of eyeballing it. The Layout Instability API reports every shift together with the nodes responsible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&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;entry&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="k"&gt;continue&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sources&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="na"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&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;s&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="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;&lt;code&gt;entry.sources[0].node&lt;/code&gt; points at the element that actually moved, which is what turns &lt;em&gt;CLS is too high&lt;/em&gt; into &lt;em&gt;the paragraph under the hero rewraps&lt;/em&gt;. The Rendering panel in Chrome DevTools has a &lt;em&gt;Layout Shift Regions&lt;/em&gt; toggle that paints the moving areas as they happen. Lighthouse confirms the fix in the lab; only field data from CrUX or your own real-user monitoring confirms it on the devices that count toward Core Web Vitals.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does next/font remove CLS on its own?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; For Latin text with &lt;code&gt;adjustFontFallback&lt;/code&gt; left at its default of &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;next/font&lt;/code&gt; removes most font-driven layout shift automatically. It does not cover non-Latin scripts, and it stops working the moment your CSS references the font family name directly instead of the CSS variable the loader returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is font-display: optional always better than swap?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;optional&lt;/code&gt; is better for CLS because it never swaps after its roughly 100 ms block period, but first-time visitors may see the fallback font for the entire page view. Use &lt;code&gt;optional&lt;/code&gt; for display faces and &lt;code&gt;swap&lt;/code&gt; with matched fallback metrics for body text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need preconnect to fonts.gstatic.com with next/font/google?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. &lt;code&gt;next/font/google&lt;/code&gt; downloads the font files at build time and serves them from your own origin, so there is no runtime connection to Google's CDN to warm up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Where do I get the ascent, descent, and xAvgCharWidth numbers for a font?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Read them from the font binary with &lt;code&gt;fontkit&lt;/code&gt; or &lt;code&gt;opentype.js&lt;/code&gt; via the &lt;code&gt;hhea&lt;/code&gt; and &lt;code&gt;OS/2&lt;/code&gt; tables, or use the published values in &lt;code&gt;@capsizecss/metrics&lt;/code&gt;, which ships metrics for common web and system fonts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does any of this apply outside Next.js?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. &lt;code&gt;fontaine&lt;/code&gt; generates the same size-adjusted fallback &lt;code&gt;@font-face&lt;/code&gt; for Vite, Nuxt, and webpack projects, and the four descriptors are plain CSS you can write by hand in any stack.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nextjs-font-loading-cls-size-adjust-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nextjs-font-loading-cls-size-adjust-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>performance</category>
      <category>webdev</category>
      <category>css</category>
    </item>
    <item>
      <title>Why My Package Broke in a CommonJS App: Field Notes on require(esm), the exports Map, and the Dual Package Hazard</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:00:25 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/why-my-package-broke-in-a-commonjs-app-field-notes-on-requireesm-the-exports-map-and-the-dual-58g1</link>
      <guid>https://dev.to/ahmed_mahmoud360/why-my-package-broke-in-a-commonjs-app-field-notes-on-requireesm-the-exports-map-and-the-dual-58g1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Node.js 22.12 and later can &lt;code&gt;require()&lt;/code&gt; a synchronous ES module, which removes the original reason most libraries shipped two builds, but it does not remove the dual package hazard and it does not forgive a badly ordered &lt;code&gt;exports&lt;/code&gt; map. The three changes that fixed my publish were putting &lt;code&gt;types&lt;/code&gt; first and &lt;code&gt;default&lt;/code&gt; last in every condition block, keeping exactly one implementation of module-level state, and running &lt;code&gt;publint&lt;/code&gt; and &lt;code&gt;@arethetypeswrong/cli&lt;/code&gt; in CI before &lt;code&gt;npm publish&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 22.12.0 enabled &lt;code&gt;require(esm)&lt;/code&gt; by default on the 22.x line, and it has been on by default since Node.js 23.0.0. A CommonJS file can now &lt;code&gt;require()&lt;/code&gt; an ES module as long as that module graph contains no top-level &lt;code&gt;await&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;require()&lt;/code&gt; of an ES module graph that uses top-level &lt;code&gt;await&lt;/code&gt; throws &lt;code&gt;ERR_REQUIRE_ASYNC_MODULE&lt;/code&gt;, not &lt;code&gt;ERR_REQUIRE_ESM&lt;/code&gt;. The two errors mean different things and have different fixes.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;exports&lt;/code&gt; field in &lt;code&gt;package.json&lt;/code&gt; matches conditions in declaration order and the first match wins, so &lt;code&gt;types&lt;/code&gt; must come first and &lt;code&gt;default&lt;/code&gt; must come last. Adding &lt;code&gt;exports&lt;/code&gt; also blocks every subpath you did not list, which surfaces as &lt;code&gt;ERR_PACKAGE_PATH_NOT_EXPORTED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The dual package hazard is what happens when one process loads both the CommonJS build and the ES module build of the same package: module-level state exists twice, singletons are no longer single, and &lt;code&gt;instanceof&lt;/code&gt; across the two copies returns &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Named imports from a CommonJS file work only when &lt;code&gt;cjs-module-lexer&lt;/code&gt; can statically detect the export names. Exports assigned in a loop are invisible to it, and the consumer must use a default import instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The package was a few hundred lines of shared parsing helpers, published as ESM-only because every app I owned was already ESM. The first team to install it ran a CommonJS service, and it failed at boot with &lt;code&gt;ERR_REQUIRE_ESM&lt;/code&gt;. My first instinct was to add a CommonJS build, which is the standard answer and also the answer that introduces the worst bug in this whole area.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can Node.js require() an ES module now?
&lt;/h2&gt;

&lt;p&gt;Yes, on Node.js 22.12.0 and later, and unflagged. &lt;code&gt;require(esm)&lt;/code&gt; lets a CommonJS file load an ES module synchronously, returning the module namespace object, with the default export available as the &lt;code&gt;.default&lt;/code&gt; property. The feature landed behind &lt;code&gt;--experimental-require-module&lt;/code&gt; earlier in the 22.x line and was enabled by default in 22.12.0, and it is on by default in Node.js 23 and 24.&lt;/p&gt;

&lt;p&gt;The single hard limit is asynchrony. &lt;code&gt;require()&lt;/code&gt; is synchronous by definition, so if the ES module graph being required contains a top-level &lt;code&gt;await&lt;/code&gt; anywhere, Node.js cannot finish evaluation before returning and throws &lt;code&gt;ERR_REQUIRE_ASYNC_MODULE&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app.cjs on Node.js 22.12+&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolkit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@acme/toolkit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// works if the ESM graph has no top-level await&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;toolkit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// the ESM default export&lt;/span&gt;

&lt;span class="c1"&gt;// If it throws ERR_REQUIRE_ASYNC_MODULE, the escape hatch is dynamic import&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@acme/toolkit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// legal inside an async function in CJS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this changes in practice: ESM-only is now a defensible choice for a new library targeting Node.js 22.12 and above. What it does not change: bundlers, older runtimes, and tools that still resolve the &lt;code&gt;require&lt;/code&gt; condition will keep asking for a CommonJS entry point for a long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my exports map resolve to the wrong file?
&lt;/h2&gt;

&lt;p&gt;Because conditional exports are matched top to bottom and the first matching key wins, so ordering is semantics, not style. The &lt;code&gt;types&lt;/code&gt; condition must be first or TypeScript may resolve a runtime file before it ever sees the declaration file. The &lt;code&gt;default&lt;/code&gt; condition must be last, because it matches everything and shadows any condition placed after 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;"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;"@acme/toolkit"&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;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&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;"."&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;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"react-server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.react-server.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"import"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.cjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&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;"./package.json"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./package.json"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sideEffects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exporting &lt;code&gt;./package.json&lt;/code&gt; explicitly keeps tools that read your manifest at runtime working, because an &lt;code&gt;exports&lt;/code&gt; field otherwise makes it unreachable. The &lt;code&gt;react-server&lt;/code&gt; condition is what React Server Components resolution uses, and Next.js resolves it for any module imported from a Server Component.&lt;/p&gt;

&lt;p&gt;The failure mode people hit hardest is subpath blocking. The moment you add &lt;code&gt;exports&lt;/code&gt;, everything not listed is private, and consumers who were deep-importing &lt;code&gt;@acme/toolkit/dist/internal/parse.js&lt;/code&gt; get &lt;code&gt;ERR_PACKAGE_PATH_NOT_EXPORTED&lt;/code&gt;. That is a breaking change even when nothing else in the package moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the dual package hazard, and how do I avoid it?
&lt;/h2&gt;

&lt;p&gt;The dual package hazard is the bug where one Node.js process loads both builds of the same package, so every module-level value exists twice. Node.js keeps separate caches for CommonJS and ES modules, so &lt;code&gt;dist/index.js&lt;/code&gt; and &lt;code&gt;dist/index.cjs&lt;/code&gt; are two distinct modules with two distinct copies of every class, registry, and counter you declared at module scope.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@acme/toolkit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// resolves ./dist/index.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TokenCjs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@acme/toolkit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// resolves ./dist/index.cjs&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TokenCjs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// false — same name, two classes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is nasty precisely because it is silent. Nothing throws. A validation check fails for a value that is obviously the right type, or a cache that should be shared reports a miss, and the stack trace points at your logic rather than at your packaging.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;What you ship&lt;/th&gt;
&lt;th&gt;Hazard risk&lt;/th&gt;
&lt;th&gt;When I pick it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ESM-only&lt;/td&gt;
&lt;td&gt;One ES module build, no &lt;code&gt;require&lt;/code&gt; condition&lt;/td&gt;
&lt;td&gt;None — only one copy can exist&lt;/td&gt;
&lt;td&gt;New libraries targeting Node.js 22.12+, and anything holding state or exporting classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESM plus a stateless CommonJS wrapper&lt;/td&gt;
&lt;td&gt;ES module implementation, plus a small CommonJS file that re-exports it&lt;/td&gt;
&lt;td&gt;None, if the wrapper holds no state&lt;/td&gt;
&lt;td&gt;Libraries that must keep older CommonJS consumers working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two full builds&lt;/td&gt;
&lt;td&gt;Independently bundled CommonJS and ES module outputs&lt;/td&gt;
&lt;td&gt;High — both graphs can load at once&lt;/td&gt;
&lt;td&gt;Only for pure functions with zero module-level state and no exported classes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My rule now is simple: if the package exports a class, holds a registry, or memoizes anything at module scope, it ships one implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't I destructure named exports from a CommonJS module?
&lt;/h2&gt;

&lt;p&gt;Because Node.js discovers the named exports of a CommonJS file by static analysis, using &lt;code&gt;cjs-module-lexer&lt;/code&gt;, and that analysis only sees assignments it can read literally in the source.&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;// legacy.cjs — the lexer cannot see these&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;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;parse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;format&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;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;impl&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// consumer.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./legacy.cjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// SyntaxError: no export named 'parse'&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;legacy&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./legacy.cjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// works: the whole module.exports object&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;parse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;legacy&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// destructure at runtime instead&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you own the CommonJS file, write the assignments out literally, one &lt;code&gt;exports.parse = ...&lt;/code&gt; per name, so the lexer can find them. If you do not own it, default-import the module object and destructure afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I configure TypeScript so the emitted output matches the package format?
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;"module": "nodenext"&lt;/code&gt; and &lt;code&gt;"moduleResolution": "nodenext"&lt;/code&gt;, and let the nearest &lt;code&gt;package.json&lt;/code&gt; &lt;code&gt;type&lt;/code&gt; field decide how each file is emitted. Under &lt;code&gt;nodenext&lt;/code&gt;, a &lt;code&gt;.ts&lt;/code&gt; file in a &lt;code&gt;"type": "module"&lt;/code&gt; package is an ES module, &lt;code&gt;.mts&lt;/code&gt; is always ESM, &lt;code&gt;.cts&lt;/code&gt; is always CommonJS, and relative imports need file extensions.&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;"compilerOptions"&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;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verbatimModuleSyntax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declaration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&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;&lt;code&gt;verbatimModuleSyntax&lt;/code&gt; tells TypeScript to emit import and export statements exactly as written, dropping only those marked &lt;code&gt;import type&lt;/code&gt;. TypeScript 5.8 taught &lt;code&gt;--module nodenext&lt;/code&gt; about &lt;code&gt;require()&lt;/code&gt; of an ES module, so a &lt;code&gt;.cts&lt;/code&gt; file requiring an ESM dependency is no longer a compile error on runtimes that support it.&lt;/p&gt;

&lt;p&gt;The declaration side has its own trap: one &lt;code&gt;.d.ts&lt;/code&gt; file cannot describe both formats correctly, because the module kind of a declaration file is inferred the same way as a source file. Dual packages need &lt;code&gt;.d.mts&lt;/code&gt; and &lt;code&gt;.d.cts&lt;/code&gt; alongside their respective entry points.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I verify a package before publishing it?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;publint&lt;/code&gt; and &lt;code&gt;@arethetypeswrong/cli&lt;/code&gt; against the packed tarball, then smoke-test both import styles in a real Node.js process. &lt;code&gt;publint&lt;/code&gt; lints the manifest for packaging errors such as condition ordering and format mismatches. &lt;code&gt;@arethetypeswrong/cli&lt;/code&gt;, invoked as &lt;code&gt;attw&lt;/code&gt;, checks that type declarations resolve correctly for every consumer configuration, and it catches masquerading types where a CommonJS entry point is described by an ESM declaration file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm pack
npx publint
npx @arethetypeswrong/cli &lt;span class="nt"&gt;--pack&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Smoke-test both resolution paths against the real runtime&lt;/span&gt;
node &lt;span class="nt"&gt;--input-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"import('@acme/toolkit').then(m =&amp;gt; console.log(Object.keys(m)))"&lt;/span&gt;
node &lt;span class="nt"&gt;--input-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;commonjs &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(Object.keys(require('@acme/toolkit')))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wire both CLIs into the same CI job that runs the build, so a bad &lt;code&gt;exports&lt;/code&gt; map fails the pipeline instead of failing someone else's install. Packaging errors are invisible in your own repository and only ever appear in a consumer's.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Does require(esm) mean I can drop my CommonJS build entirely?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only if every consumer runs Node.js 22.12 or later and no bundler in their toolchain insists on the &lt;code&gt;require&lt;/code&gt; condition. For a library with unknown consumers, a thin stateless CommonJS wrapper is cheap insurance and carries no dual package hazard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What is the difference between ERR_REQUIRE_ESM and ERR_REQUIRE_ASYNC_MODULE?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;ERR_REQUIRE_ESM&lt;/code&gt; means the runtime will not load an ES module through &lt;code&gt;require()&lt;/code&gt; at all, which on modern Node.js usually points at resolution picking an unexpected file. &lt;code&gt;ERR_REQUIRE_ASYNC_MODULE&lt;/code&gt; means the runtime tried and stopped because the module graph uses top-level &lt;code&gt;await&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why did adding an exports field break my consumers?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; An &lt;code&gt;exports&lt;/code&gt; field makes every path you did not list unreachable, so any consumer deep-importing an internal file now gets &lt;code&gt;ERR_PACKAGE_PATH_NOT_EXPORTED&lt;/code&gt;. Publish it as a major version, or list the previously reachable subpaths explicitly during a deprecation window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do I still need the main field in package.json?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Keep &lt;code&gt;main&lt;/code&gt; as a fallback for tooling that predates conditional exports. Node.js ignores &lt;code&gt;main&lt;/code&gt; whenever an &lt;code&gt;exports&lt;/code&gt; field is present, so it costs nothing and buys compatibility with older bundlers and resolvers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I get __dirname in an ES module?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Use &lt;code&gt;import.meta.dirname&lt;/code&gt; and &lt;code&gt;import.meta.filename&lt;/code&gt;, available since Node.js 20.11 and 21.2. For older runtimes, derive it with &lt;code&gt;fileURLToPath(import.meta.url)&lt;/code&gt; from &lt;code&gt;node:url&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/esm-cjs-interop-require-esm-dual-package-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/esm-cjs-interop-require-esm-dual-package-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why My LLM Feature Cost More Than It Should: Field Notes on Prompt Caching, Cache Breakpoints, and the Timestamp That Killed Every Hit</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:00:09 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/why-my-llm-feature-cost-more-than-it-should-field-notes-on-prompt-caching-cache-breakpoints-and-46jd</link>
      <guid>https://dev.to/ahmed_mahmoud360/why-my-llm-feature-cost-more-than-it-should-field-notes-on-prompt-caching-cache-breakpoints-and-46jd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Prompt caching saves money only when the cached prefix is byte-identical across requests, and a single interpolated timestamp in a system prompt is enough to make every call a cache write instead of a cache read. The three things that fixed my bill were ordering the request static-first (tools, then system, then messages), putting the &lt;code&gt;cache_control&lt;/code&gt; breakpoint at the end of the stable region, and reading &lt;code&gt;cache_read_input_tokens&lt;/code&gt; from every response instead of assuming caching was working.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Prompt caching is a provider-side store of the processed prefix of a request, so a later request with the identical prefix skips re-processing it. With the Anthropic Messages API it is opt-in per content block: add &lt;code&gt;cache_control: { type: "ephemeral" }&lt;/code&gt; to a block and everything up to and including that block becomes the cached prefix. A request may carry at most four breakpoints.&lt;/li&gt;
&lt;li&gt;A cache read is billed at 0.1x the base input token price. A five-minute cache write is billed at 1.25x and a one-hour write at 2x. The extra 0.25x paid on a five-minute write is repaid by the first hit inside the window, because each hit saves 0.9x.&lt;/li&gt;
&lt;li&gt;The cache key is the exact serialized prefix in the fixed order tools, then system, then messages. A timestamp in the system prompt, a tool array built by iterating a &lt;code&gt;Map&lt;/code&gt;, or a swapped model id all invalidate from the point of change onward.&lt;/li&gt;
&lt;li&gt;Every Messages API response reports &lt;code&gt;input_tokens&lt;/code&gt;, &lt;code&gt;cache_creation_input_tokens&lt;/code&gt;, and &lt;code&gt;cache_read_input_tokens&lt;/code&gt;. Cache hit rate is &lt;code&gt;read / (read + creation + input)&lt;/code&gt;, and it is the only honest way to know whether caching is on.&lt;/li&gt;
&lt;li&gt;Prompt caching does not reduce output token cost and does not fix a bloated prompt. Deleting four thousand tokens of context nobody reads beats caching them at 0.1x forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The feature was a support agent with a large tool schema and a long API reference pinned into the system prompt, which is exactly the shape prompt caching is built for. I added a breakpoint, deployed, and the cost per conversation did not move. The usage object was blunt about it: &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; was populated on every single request and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; was zero on every single request. I was paying 1.25x to write a cache entry that nothing ever read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is prompt caching, and what am I actually paying for?
&lt;/h2&gt;

&lt;p&gt;Prompt caching lets the provider reuse the work it already did on a prefix of your prompt, so identical leading tokens are billed at a steep discount instead of being processed again. It splits your input bill into three separate line items rather than one: fresh uncached input, cache writes, and cache reads. Reading those three numbers is the whole discipline.&lt;/p&gt;

&lt;p&gt;Two constraints decide whether a breakpoint does anything at all. The first is the minimum cacheable prefix, which is 1024 tokens on the larger Claude models and 2048 on the smallest ones; below the minimum the &lt;code&gt;cache_control&lt;/code&gt; marker is ignored and no error is raised, which looks exactly like caching being broken. The second is the time to live: the default entry lives five minutes and every read refreshes that window, so a steady stream of requests keeps a prefix warm indefinitely without paying to write it again. Passing &lt;code&gt;ttl: "1h"&lt;/code&gt; extends the window at a higher write price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do I put the cache breakpoint?
&lt;/h2&gt;

&lt;p&gt;Put the breakpoint on the boundary between what never changes per request and what always changes. The provider serializes tools first, then the system prompt, then messages, so anything you want cached must live before anything volatile. A large tool schema is usually the biggest static block in an agent request and is the best first thing to get behind a breakpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TOOLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// serialized first, so it sits inside the cached prefix&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AGENT_INSTRUCTIONS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_REFERENCE_DOC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cache_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// breakpoint: cache everything above&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// non-zero on the cold call&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// non-zero on every warm call&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a multi-turn conversation I use two breakpoints rather than one. The first sits at the end of the tools and system region, which never changes for the life of the deployment. The second sits at the end of the last completed turn, so the growing transcript is cached incrementally and each new turn only pays full price for the tokens the user just added.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my cache hit rate drop to zero?
&lt;/h2&gt;

&lt;p&gt;A cache hit requires the prefix to be identical byte for byte, so anything that varies per request destroys every hit from that point onward. My own failure was one interpolated clock reading in the system prompt, added months earlier so the agent could reason about business hours.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interpolated volatile values.&lt;/strong&gt; A timestamp, a request id, a user name, or a session id rendered into the system prompt changes the prefix on every call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-deterministic ordering.&lt;/strong&gt; A tool array built by iterating an object or a &lt;code&gt;Set&lt;/code&gt;, or a JSON body serialized with unstable key order, produces a different prefix from identical data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model changes.&lt;/strong&gt; Cache entries are keyed per model, so moving from one model id to another starts cold, and so does an alias that silently points somewhere new.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware that rewrites the request.&lt;/strong&gt; A proxy or gateway that injects a header-derived block or reorders messages breaks the prefix even though your application code is unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trimming a conversation from the front.&lt;/strong&gt; Dropping the oldest messages to fit a context budget rewrites the start of the prefix and invalidates everything.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Breaks every hit: the prefix is different on every request&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are a support agent. Current time: &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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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="c1"&gt;// Fix: keep the system prompt frozen, move volatile facts after the breakpoint&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;system&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STATIC_INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cache_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ephemeral&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;messages&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Current time: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&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;h2&gt;
  
  
  When is a one-hour cache TTL worth 2x the write price?
&lt;/h2&gt;

&lt;p&gt;The break-even point is pure arithmetic on the published multipliers, not a benchmark. A five-minute write costs 0.25x more than an uncached request and each hit saves 0.9x, so the first hit inside the window already pays for the write. A one-hour write costs 1x more, so it needs roughly two hits to come out ahead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Write price&lt;/th&gt;
&lt;th&gt;Read price&lt;/th&gt;
&lt;th&gt;Hits to break even&lt;/th&gt;
&lt;th&gt;Where I use it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No caching&lt;/td&gt;
&lt;td&gt;1x input&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Prefixes below the model minimum, or genuinely one-shot calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five-minute cache&lt;/td&gt;
&lt;td&gt;1.25x input&lt;/td&gt;
&lt;td&gt;0.1x input&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Chat turns and agent loops, where a live user keeps traffic flowing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-hour cache&lt;/td&gt;
&lt;td&gt;2x input&lt;/td&gt;
&lt;td&gt;0.1x input&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;A long document reused across a work session, or a batch spread over an hour&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Concurrency changes the math in one specific way. The entry is created by the request that writes it, so firing ten cold requests in parallel means paying the write price ten times. When I fan out, I send one warm-up call first and start the fan-out after it returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure cache hits instead of guessing?
&lt;/h2&gt;

&lt;p&gt;Log the three input counters on every response and compute a hit rate, because caching fails silently and looks identical to caching that was never enabled. I emit the ratio as a metric and treat a drop after a deploy as expected rather than alarming, since editing a prompt is by definition a cache invalidation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;written&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="na"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&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;hitRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;written&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;written&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hitRate&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;llm.usage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through the Vercel AI SDK the same numbers arrive under provider metadata instead of a top-level usage object. Marking a message with &lt;code&gt;providerOptions.anthropic.cacheControl&lt;/code&gt; sets the breakpoint, and &lt;code&gt;providerMetadata.anthropic&lt;/code&gt; on the result carries &lt;code&gt;cacheCreationInputTokens&lt;/code&gt; and &lt;code&gt;cacheReadInputTokens&lt;/code&gt;. If those fields are absent, the request never reached the provider in a cacheable shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does prompt caching not fix, and how do other providers do it?
&lt;/h2&gt;

&lt;p&gt;Caching is an input-side optimization only. It does not change the model output, it does not reduce output token price, and it does not make a badly scoped prompt cheap. The single biggest cost win I found was not the cache at all; it was deleting a stale section of the reference document that no answer had ever cited.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; applies prompt caching automatically to prompts above roughly 1024 tokens, with no explicit breakpoint to place, and reports cached tokens in the usage payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Gemini&lt;/strong&gt; exposes both implicit caching and an explicit context caching API where you create a cached content handle with its own TTL and reference it by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt; is the explicit one: you choose the breakpoints, which costs a design decision and buys precise control over what is cached.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The portable lesson survives all three: structure the request static-first, keep the volatile parts last, and verify with the provider's own token counters.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Does prompt caching change the model's output?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Prompt caching reuses the processed prefix and does not alter sampling, so the same prompt and parameters behave the same whether the prefix was read from cache or processed fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can another organization read my cached prompt?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Cache entries are scoped to your own organization and keyed on the exact prefix, so there is no cross-organization sharing of cached content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does the five-minute TTL reset on every hit?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. Each cache read refreshes the five-minute window, so continuous traffic keeps a prefix alive without ever paying the write price again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why do I still see cache creation tokens on every request when nothing changed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Something in the prefix is changing, most often an interpolated value or a non-deterministic key order. Hash the serialized prefix of two consecutive requests and diff the two strings; the difference is always visible once you look at the bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I cache tool definitions or the system prompt first?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Tools, because they are serialized before the system prompt and are usually the largest static block in an agent request. Placing the breakpoint after the system prompt covers the tools as well, since the cached prefix is everything up to the breakpoint.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/llm-prompt-caching-cost-control-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/llm-prompt-caching-cost-control-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why My TypeScript Typecheck Got Slow: Field Notes on tsgo, Project References, and isolatedDeclarations</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Tue, 06 Oct 2026 06:36:41 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/why-my-typescript-typecheck-got-slow-field-notes-on-tsgo-project-references-and-2lhp</link>
      <guid>https://dev.to/ahmed_mahmoud360/why-my-typescript-typecheck-got-slow-field-notes-on-tsgo-project-references-and-2lhp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A slow &lt;code&gt;tsc --noEmit&lt;/code&gt; is almost never caused by the number of files in a repository; it is caused by how much type &lt;em&gt;instantiation&lt;/em&gt; the checker has to do, and &lt;code&gt;--extendedDiagnostics&lt;/code&gt; tells you which in about ten seconds. The three changes that cut my typecheck time were measuring before guessing, splitting one whole-repo &lt;code&gt;tsconfig.json&lt;/code&gt; into project references built with &lt;code&gt;tsc -b&lt;/code&gt;, and running the Go-based native port &lt;code&gt;tsgo&lt;/code&gt; as a second checker in CI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tsc --noEmit --extendedDiagnostics&lt;/code&gt; prints Files, Types, Instantiations, Memory used, and a time breakdown across Parse, Bind, Check, and Emit. Read that output before changing any configuration, because the fix for a slow Parse phase and the fix for a slow Check phase have nothing in common.&lt;/li&gt;
&lt;li&gt;Instantiations — the count of times the checker materialises a generic type with concrete arguments — predicts checker cost far better than file count. A project with 2,000 files and millions of instantiations checks slower than one with 8,000 files and few.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tsgo&lt;/code&gt; is the TypeScript native port: a rewrite of the compiler and language service in Go, distributed as the &lt;code&gt;@typescript/native-preview&lt;/code&gt; npm package and intended to ship as TypeScript 7. It is the only item on this list that speeds up checking without changing a line of your code.&lt;/li&gt;
&lt;li&gt;Project references — &lt;code&gt;composite: true&lt;/code&gt; in each package plus a &lt;code&gt;references&lt;/code&gt; array in the consumer, built with &lt;code&gt;tsc -b&lt;/code&gt; — turn one whole-repo check into a graph of independently cacheable checks. They pay off only when the dependency graph is genuinely layered.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;isolatedDeclarations&lt;/code&gt;, added in TypeScript 5.5, requires explicit type annotations on exported values, and in exchange makes a &lt;code&gt;.d.ts&lt;/code&gt; file derivable from a single source file. That is the property that lets declaration emit be parallelised or handed to a non-TypeScript tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first symptom was not CI. It was the moment I started alt-tabbing away during &lt;code&gt;npm run typecheck&lt;/code&gt;, which is how a build step quietly stops being part of the inner loop. My assumption was the obvious one — the repository had grown, so of course the compiler was slower. That assumption was wrong, and I only found out because I ran the diagnostics flag before touching the configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does tsc actually spend its time?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tsc&lt;/code&gt; splits its work into four measurable phases — Parse, Bind, Check, and Emit — and in a large application Check almost always dominates. &lt;code&gt;--extendedDiagnostics&lt;/code&gt; prints the split alongside the counts that explain it, so it is the first command to run and the only honest starting point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="nt"&gt;--extendedDiagnostics&lt;/span&gt;

&lt;span class="c"&gt;# if Check dominates, find the specific hot spots&lt;/span&gt;
npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="nt"&gt;--generateTrace&lt;/span&gt; .trace
npx @typescript/analyze-trace .trace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--generateTrace&lt;/code&gt; writes a Chrome-tracing-format file, and the &lt;code&gt;@typescript/analyze-trace&lt;/code&gt; package reads it back as a ranked list of the files and type instantiations that cost the most milliseconds. That ranking is usually surprising: in my case a single utility module exporting inferred return types was responsible for a disproportionate share of the check, and nobody would have guessed it from reading the code.&lt;/p&gt;

&lt;p&gt;Two configuration flags are worth confirming before any structural work. &lt;code&gt;skipLibCheck: true&lt;/code&gt; stops the compiler from type checking the contents of &lt;code&gt;.d.ts&lt;/code&gt; files, which on a large &lt;code&gt;node_modules&lt;/code&gt; is often the single cheapest win available; your own source is still checked against those declarations. And &lt;code&gt;types: []&lt;/code&gt; in &lt;code&gt;compilerOptions&lt;/code&gt; stops TypeScript from automatically including every package under &lt;code&gt;node_modules/@types&lt;/code&gt;, which matters once a repository has accumulated a decade of ambient globals nobody imports.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is tsgo and should I use it now?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tsgo&lt;/code&gt; is a port of the TypeScript compiler and language service from TypeScript to Go, announced by the TypeScript team in March 2025 and published as a preview under the &lt;code&gt;@typescript/native-preview&lt;/code&gt; package. The plan the team described is that the native port becomes TypeScript 7 while the existing JavaScript implementation continues as TypeScript 6, so both lines exist during the transition. The team's published benchmarks report order-of-magnitude improvements in check time and editor load time on the repositories they measured.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @typescript/native-preview
npx tsgo &lt;span class="nt"&gt;--noEmit&lt;/span&gt;        &lt;span class="c"&gt;# same flags you already pass to tsc&lt;/span&gt;
npx tsgo &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I do not treat it as a replacement yet, and neither should you while it carries a preview label. What I do instead is cheap and has caught nothing but time savings so far: add a second, non-blocking CI job that runs &lt;code&gt;tsgo --noEmit&lt;/code&gt; next to the real &lt;code&gt;tsc --noEmit&lt;/code&gt; job, and compare the error output. When the two agree on every pull request for a month, the preview binary has earned the right to become the blocking one.&lt;/p&gt;

&lt;p&gt;The honest limits: the preview does not implement every compiler flag or the full public compiler API, so anything that plugs into TypeScript programmatically — custom transformers, ESLint's type-aware rules through &lt;code&gt;@typescript-eslint&lt;/code&gt;, codemod tooling built on &lt;code&gt;ts-morph&lt;/code&gt; — still runs against the JavaScript compiler. A native-preview VS Code extension exists for the editor side, and the same caveat applies there.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do project references actually help?
&lt;/h2&gt;

&lt;p&gt;Project references help when the repository is a layered dependency graph, and do nothing when it is a ball of mud. A composite project emits &lt;code&gt;.d.ts&lt;/code&gt; files and a &lt;code&gt;.tsbuildinfo&lt;/code&gt; fingerprint, and &lt;code&gt;tsc -b&lt;/code&gt; walks the reference graph in dependency order and skips any project whose inputs have not changed. If &lt;code&gt;packages/ui&lt;/code&gt; imports &lt;code&gt;packages/core&lt;/code&gt; and nothing else, editing &lt;code&gt;packages/ui&lt;/code&gt; should never re-check &lt;code&gt;packages/core&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;packages/core/tsconfig.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&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;"composite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declaration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"declarationMap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rootDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;apps/web/tsconfig.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&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;"noEmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"references"&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;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"../../packages/core"&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;Three details decide whether this is worth the migration. A referenced project must emit — &lt;code&gt;composite: true&lt;/code&gt; implies &lt;code&gt;declaration: true&lt;/code&gt; — because the consumer type checks against the emitted &lt;code&gt;.d.ts&lt;/code&gt; rather than against source, which is precisely where the caching comes from. Set &lt;code&gt;declarationMap: true&lt;/code&gt; or every go-to-definition in your editor lands in a generated declaration file instead of the real implementation. And run &lt;code&gt;tsc -b --verbose&lt;/code&gt; once after wiring it up: it names each project it skips as up to date, which is the only direct evidence that the cache is working rather than silently rebuilding everything.&lt;/p&gt;

&lt;p&gt;The failure case is worth stating plainly. In a monorepo where every package imports every other package, the reference graph has no layers, every change invalidates every project, and you have added configuration complexity for no cache hits. Fix the import graph first, or skip references entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does isolatedDeclarations buy?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;isolatedDeclarations&lt;/code&gt; is a TypeScript 5.5 compiler flag that reports an error whenever a declaration file cannot be produced from one source file in isolation — in practice, whenever an exported value's type is inferred from something in another module. Turning it on is a code change, not just a config change, because it forces explicit annotations on your exports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tsconfig.json → { "compilerOptions": { "isolatedDeclarations": true, "declaration": true } }&lt;/span&gt;

&lt;span class="c1"&gt;// error TS9007: Declaration emit for this file requires an explicit type annotation.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeClient&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&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;// accepted: the exported shape is written down, not inferred&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeClient&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&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 payoff is that declaration emit stops needing a whole-program type check, so it can be parallelised per file or delegated to a faster non-TypeScript emitter — the Rust-based &lt;code&gt;oxc&lt;/code&gt; toolchain implements exactly this, and bundlers built on it expose it as a &lt;code&gt;.d.ts&lt;/code&gt; generation mode. The secondary benefit shows up in the consuming project: an exported function with a written return type is a type the checker reads instead of a type it re-derives.&lt;/p&gt;

&lt;p&gt;Where I would enable it: packages that publish declarations, and internal workspace packages consumed via project references. Where I would not: an application's leaf feature code, where the annotation burden is real and there is no &lt;code&gt;.d.ts&lt;/code&gt; emit to accelerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which type patterns are expensive?
&lt;/h2&gt;

&lt;p&gt;The expensive patterns are the ones that make the checker do work per call site rather than once. Deeply nested conditional types, recursive template literal types, large unions that get distributed across a mapped type, and long inference chains through schema libraries all share that shape: the cost is paid again everywhere the type is used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the return type is re-inferred through the schema at every call site&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parseUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// named once, then read rather than re-derived&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parseUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two smaller habits move the Instantiations number. Prefer &lt;code&gt;interface X extends A, B&lt;/code&gt; over &lt;code&gt;type X = A &amp;amp; B&lt;/code&gt; for large object types, because interfaces are resolved lazily and cached by name while intersections are recomputed structurally. And annotate the return type of every exported function, which is the same discipline &lt;code&gt;isolatedDeclarations&lt;/code&gt; enforces mechanically.&lt;/p&gt;

&lt;p&gt;One clarification that saves a lot of confused debugging: a fast CLI check and a slow editor are not a contradiction. The command line runs &lt;code&gt;tsc&lt;/code&gt; once over the whole program, while your editor runs &lt;code&gt;tsserver&lt;/code&gt;, which maintains an incremental program around the files you have open and answers completion and hover requests against it. If the CLI is quick and typing is laggy, the problem is in the editor's project scope — open the TS Server log from the command palette and check which &lt;code&gt;tsconfig.json&lt;/code&gt; it decided your file belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which lever should I pull first?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skipLibCheck: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skips type checking inside &lt;code&gt;.d.ts&lt;/code&gt; files&lt;/td&gt;
&lt;td&gt;Almost always; it is the cheapest single flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;incremental: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reuses the previous run's &lt;code&gt;.tsbuildinfo&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Local watch loops, and CI only if you cache the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsgo&lt;/code&gt; (native port)&lt;/td&gt;
&lt;td&gt;Runs the same check on the Go compiler&lt;/td&gt;
&lt;td&gt;You want speed without code changes; run beside &lt;code&gt;tsc&lt;/code&gt; first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project references + &lt;code&gt;tsc -b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Per-package caching across a layered graph&lt;/td&gt;
&lt;td&gt;A monorepo whose packages form a real DAG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isolatedDeclarations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-file, parallelisable &lt;code&gt;.d.ts&lt;/code&gt; emit&lt;/td&gt;
&lt;td&gt;You publish packages or declaration emit dominates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit return types on exports&lt;/td&gt;
&lt;td&gt;Removes repeated inference at call sites&lt;/td&gt;
&lt;td&gt;Instantiations is high relative to file count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lesson I would keep if I lost the rest: the compiler already reports where its time goes, and I spent two afternoons tuning things that were never the bottleneck because I did not ask it. &lt;code&gt;--extendedDiagnostics&lt;/code&gt; costs one command. Run it before you migrate anything.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does &lt;code&gt;skipLibCheck: true&lt;/code&gt; hide real errors in my own code?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. It only skips type checking &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;.d.ts&lt;/code&gt; files; your source is still checked against those declarations. The trade-off is that genuinely conflicting types between two libraries will not be reported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is &lt;code&gt;tsgo&lt;/code&gt; safe to use as the only typecheck in CI today?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not while it ships as &lt;code&gt;@typescript/native-preview&lt;/code&gt;. Run it as a second, non-blocking job alongside &lt;code&gt;tsc --noEmit&lt;/code&gt; and compare error output over real pull requests before making it authoritative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why is my editor slow when the command-line typecheck is fast?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; The editor runs &lt;code&gt;tsserver&lt;/code&gt; with its own incremental program scoped to the &lt;code&gt;tsconfig.json&lt;/code&gt; it matched your file to, which may include far more files than you expect. Open the TS Server log from the command palette to see the resolved project and its file count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does &lt;code&gt;incremental: true&lt;/code&gt; help in CI?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Only if the &lt;code&gt;.tsbuildinfo&lt;/code&gt; file is cached and restored between runs. On a clean checkout with no restored cache, the flag costs a small amount of write time and saves nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do project references speed up &lt;code&gt;next build&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; They speed up the workspace packages the app consumes, not the app's own check. The common arrangement is to set &lt;code&gt;typescript.ignoreBuildErrors&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt; and run a dedicated typecheck job in parallel, so a type error fails CI without serialising the build behind the checker.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/typescript-typecheck-performance-tsgo-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/typescript-typecheck-performance-tsgo-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>performance</category>
    </item>
    <item>
      <title>How I Test an LLM Feature: Field Notes on Golden Sets, LLM-as-Judge, and Eval Gates in CI</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:00:13 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/how-i-test-an-llm-feature-field-notes-on-golden-sets-llm-as-judge-and-eval-gates-in-ci-3aog</link>
      <guid>https://dev.to/ahmed_mahmoud360/how-i-test-an-llm-feature-field-notes-on-golden-sets-llm-as-judge-and-eval-gates-in-ci-3aog</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; An LLM feature cannot be unit-tested with string equality, because the same prompt returns different text on every call, so I assert on properties of the output instead of the output itself. The three changes that made my AI features safe to refactor were a version-controlled golden set of real inputs, deterministic assertions running before any LLM-as-judge call, and a CI gate on pass-rate &lt;em&gt;regression&lt;/em&gt; rather than on a perfect score.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An eval is a test that runs a prompt against fixed inputs and scores the output on properties rather than on exact text. Evals replace &lt;code&gt;toEqual&lt;/code&gt; assertions for any code path whose output comes from a model.&lt;/li&gt;
&lt;li&gt;A golden set is a version-controlled file of real inputs paired with the properties their outputs must satisfy. Every production bug I fixed in an AI feature became one more row in that file.&lt;/li&gt;
&lt;li&gt;Deterministic assertions — Zod schema validation, required substrings, forbidden strings, numeric ranges — catch most regressions, run in milliseconds, and cost nothing. Run them before any judge call.&lt;/li&gt;
&lt;li&gt;LLM-as-judge is a second model call that scores the first model's output against a written rubric. A judge is only trustworthy when its model ID is pinned, its rubric asks binary questions, and its verdicts have been compared against human labels.&lt;/li&gt;
&lt;li&gt;Gate CI on regression, not perfection: fail the build when the pass rate drops below a baseline committed to the repository, and treat raising that baseline as a reviewed commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not plan to build an eval harness. I shipped a route that turns a free-text support message into a structured ticket, and it worked. Weeks later a colleague edited the system prompt for an unrelated reason — a wording tweak in the tone instructions — and the extraction started inventing order IDs for messages that contained none. Every test in the repository still passed, because the only tests were for the code &lt;em&gt;around&lt;/em&gt; the model. Nothing in CI was looking at what the model said. That is the gap evals fill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't I unit-test an LLM feature the normal way?
&lt;/h2&gt;

&lt;p&gt;A large language model is a non-deterministic function, so &lt;code&gt;expect(output).toBe('...')&lt;/code&gt; fails for reasons unrelated to correctness. Setting &lt;code&gt;temperature: 0&lt;/code&gt; makes output far more stable but does not make it byte-identical: providers batch requests and floating-point accumulation differs between batches, so no major provider guarantees reproducible tokens. Asserting on exact strings produces a suite that is red on Tuesday and green on Wednesday, which teams learn to ignore within a sprint.&lt;/p&gt;

&lt;p&gt;The useful move is to split the code at the model boundary. Prompt assembly, retry logic, output parsing, tool dispatch, and every branch that consumes the parsed result are ordinary deterministic functions and deserve ordinary unit tests. Only the single call that crosses into the model needs an eval. Once the seam exists, the eval asserts on properties that are true of any correct answer: does it parse against the schema, does it stay in the requested language, does it cite only identifiers that appear in the input, does it refuse when the context contains no answer, does it never echo the system prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a golden set and where do the cases come from?
&lt;/h2&gt;

&lt;p&gt;A golden set is a version-controlled file of real inputs paired with the properties their outputs must satisfy. Mine started at roughly a dozen cases — one per input shape I could name — and grew every time production surprised me. I do not write synthetic cases when real ones exist, because the cases that catch regressions are the awkward real ones: the empty message, the message written in Arabic, the message that is only an order number, the message that is a prompt-injection attempt pasted out of an email.&lt;/p&gt;

&lt;p&gt;Two storage rules have paid for themselves. First, keep the fixtures as JSON or JSONL next to the prompt they test, so the case list is reviewable in a pull request. Second, keep the prompt in its own file rather than in a template literal inside a route handler, so a prompt change and its eval results show up in the same diff.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// evals/ticket-extraction.eval.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateObject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./fixtures/tickets.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TICKET_PROMPT&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../src/prompts/ticket&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;Ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing&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;bug&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;feature&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;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;urgency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&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;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&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;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;orderIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticket extraction&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;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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concurrent&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;name&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// pinned ID, routed via AI Gateway&lt;/span&gt;
        &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;temperature&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;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TICKET_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;prompt&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;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// grounding: never invent an order ID that is not in the message&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;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;expect&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;input&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;generateObject&lt;/code&gt; from the Vercel AI SDK is doing double duty here: it validates the model output against the Zod schema before returning, so a shape regression throws instead of flowing into an assertion. That is the cheapest eval in the file and it is the one that would have caught my invented-order-ID bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use a deterministic check instead of LLM-as-judge?
&lt;/h2&gt;

&lt;p&gt;Use a deterministic check whenever the failure can be expressed as a predicate over the string, and reach for a judge only for properties that require reading comprehension. Deterministic checks are free, instant, and never disagree with themselves; a judge is a second model call with its own error rate and its own bill.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property to check&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Output shape and JSON validity&lt;/td&gt;
&lt;td&gt;Deterministic (Zod schema)&lt;/td&gt;
&lt;td&gt;Schema validation is exact; a judge adds cost and can only be worse.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grounding: every ID, price, or quote appears in the input&lt;/td&gt;
&lt;td&gt;Deterministic (substring or set check)&lt;/td&gt;
&lt;td&gt;Hallucinated identifiers are string-detectable, and this is the most damaging class of error.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forbidden content: prompt leakage, internal URLs, competitor names&lt;/td&gt;
&lt;td&gt;Deterministic (regex denylist)&lt;/td&gt;
&lt;td&gt;Safety properties must be exact, not probabilistic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language, length, and format constraints&lt;/td&gt;
&lt;td&gt;Deterministic&lt;/td&gt;
&lt;td&gt;Word counts and script ranges are trivially computable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did the answer actually address the question?&lt;/td&gt;
&lt;td&gt;LLM-as-judge&lt;/td&gt;
&lt;td&gt;Requires comprehension; no predicate expresses it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the answer semantically equivalent to a reference?&lt;/td&gt;
&lt;td&gt;LLM-as-judge or embedding similarity&lt;/td&gt;
&lt;td&gt;Many correct phrasings exist, so exact matching under-reports success.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How do I keep the LLM judge from drifting?
&lt;/h2&gt;

&lt;p&gt;Treat the judge as production code with a version, not as an oracle. Pin the exact model ID — &lt;code&gt;claude-sonnet-5&lt;/code&gt;, never a floating alias — because a silent provider-side model change reads as a quality regression in your product. Ask binary questions instead of 1–10 scores: "does the answer state a refund deadline, yes or no" is reproducible, while "rate helpfulness out of ten" drifts and turns your threshold into an arbitrary number. Keep the rubric in a committed file that is reviewed like any other source. And calibrate: hand-label twenty to thirty outputs yourself, run the judge over the same outputs, and look at where the two disagree. A judge you have never compared to a human is a number, not a measurement. Where budget allows, use a different model family for the judge than for the generator, because models tend to rate their own phrasing style favourably.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;grounded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;answersQuestion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&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;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// pinned; bump in a reviewed commit&lt;/span&gt;
    &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;temperature&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;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JUDGE_RUBRIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// committed file, binary criteria only&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;context&amp;gt;&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="s2"&gt;&amp;lt;/context&amp;gt;\n&amp;lt;question&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/question&amp;gt;\n&amp;lt;answer&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/answer&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;object&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;reason&lt;/code&gt; field is not decoration. When an eval fails at 2am, a one-line explanation from the judge is the difference between a triageable failure and a red square nobody can interpret.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I run evals in CI without paying for every pull request?
&lt;/h2&gt;

&lt;p&gt;Run evals in two tiers. Tier one is a fast subset on every pull request: all deterministic checks plus a handful of representative cases, no judge calls. Tier two is the full set with judging, triggered nightly and on any pull request that touches the prompt or fixture directories — that is a &lt;code&gt;paths&lt;/code&gt; filter in the workflow, not a human convention. A documentation-only pull request should cost nothing.&lt;/p&gt;

&lt;p&gt;Three details keep tier one honest. Model calls are IO-bound, so run cases concurrently with &lt;code&gt;it.concurrent&lt;/code&gt; and cap concurrency below the provider rate limit. Give CI its own API key routed through a single gateway, so eval spend is a legible line item instead of a mystery inside the product budget. And expect genuine flakiness: a case that fails once and passes on retry is a signal about output variance, so I retry a failed case once and only fail the build when it fails twice.&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="c1"&gt;# .github/workflows/evals.yml&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;evals&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/prompts/**'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;evals/**'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/lib/ai/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;full&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;24'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&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 ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;npx vitest run evals --maxConcurrency=4&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;AI_GATEWAY_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AI_GATEWAY_EVAL_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What pass rate should fail the build?
&lt;/h2&gt;

&lt;p&gt;Gate on regression rather than on a fixed absolute score. A rule of "100% of evals must pass" fails constantly on borderline cases and gets disabled within a month. Instead I commit an &lt;code&gt;evals/baseline.json&lt;/code&gt; holding the current pass rate per suite, and CI fails when the measured rate drops below the baseline by more than a small tolerance. Raising the baseline after a genuine improvement is a normal reviewed commit, which makes quality a ratcheting number that lives in git history rather than a claim in a standup.&lt;/p&gt;

&lt;p&gt;Two categories are exempt from the statistics. Schema validity and safety checks — prompt leakage, invented identifiers, denylisted content — hard-fail at zero tolerance, because a single occurrence is a bug rather than a sample. Keeping those separate is what lets the fuzzy score stay fuzzy without anyone arguing that a leak is "within tolerance".&lt;/p&gt;

&lt;p&gt;I still do not have a perfect measurement of whether the ticket extractor is good. What I have is a file of the ways it has been wrong, a build that goes red when it gets worse in one of those ways, and permission to refactor the prompt without holding my breath. For a non-deterministic feature, that has turned out to be the whole win.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need a dedicated eval framework to start?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Vitest or the built-in &lt;code&gt;node:test&lt;/code&gt; runner plus a JSON fixture file covers the first several months. Tools such as Promptfoo, Evalite, and Braintrust become worth adopting once you want dataset management, run-over-run tracing, and a UI for reviewing judge disagreements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How many cases should a golden set contain?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Start with one case per input shape you can name, which is usually ten to twenty, and grow it from production failures. Coverage of distinct failure modes matters far more than the case count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does &lt;code&gt;temperature: 0&lt;/code&gt; make model output deterministic?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It sharply reduces variance but no major provider guarantees byte-identical output, because request batching changes floating-point accumulation. Assert on properties of the output regardless of temperature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should the judge model be the same model that generated the answer?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Prefer a different model family when budget allows, since models tend to favour their own phrasing style. If you must reuse the same model, calibrate the judge against human labels before trusting its scores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I stop eval costs from growing with the team?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Run deterministic checks first and skip judging any case that already failed one, restrict full runs to prompt-touching pull requests and a nightly schedule, and cache model responses keyed by a hash of prompt file, model ID, and input.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/llm-feature-evals-ci-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/llm-feature-evals-ci-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why My Next.js Client Bundle Was Bigger Than I Thought: Barrel Files, optimizePackageImports, and the 'use client' Boundary</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/why-my-nextjs-client-bundle-was-bigger-than-i-thought-barrel-files-optimizepackageimports-and-212p</link>
      <guid>https://dev.to/ahmed_mahmoud360/why-my-nextjs-client-bundle-was-bigger-than-i-thought-barrel-files-optimizepackageimports-and-212p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; One &lt;code&gt;'use client'&lt;/code&gt; directive pulls that file's entire import graph into the browser bundle, and a barrel &lt;code&gt;index.ts&lt;/code&gt; re-exporting two hundred modules is how that graph gets large without anyone noticing. The changes that actually moved my First Load JS were importing modules by their real path instead of through a barrel, enabling &lt;code&gt;experimental.optimizePackageImports&lt;/code&gt;, and pushing the &lt;code&gt;'use client'&lt;/code&gt; boundary down to the leaf that needs it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A barrel file is an &lt;code&gt;index.ts&lt;/code&gt; whose only job is to re-export other modules. Importing one named symbol from a barrel makes the bundler resolve and evaluate every module that barrel names before it can decide what to delete.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'use client'&lt;/code&gt; marks a boundary, not a single file. Every module a client component imports, and every module those import in turn, is compiled into the browser bundle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;experimental.optimizePackageImports&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt; rewrites barrel imports into direct module paths at build time. Next.js already applies it to a built-in list including &lt;code&gt;lucide-react&lt;/code&gt; and &lt;code&gt;@mui/icons-material&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"sideEffects": false&lt;/code&gt; in a package's &lt;code&gt;package.json&lt;/code&gt; permits a bundler to delete unused re-exports. A package that ships CSS must use &lt;code&gt;"sideEffects": ["*.css"]&lt;/code&gt; instead, or the stylesheet is tree-shaken away.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next build&lt;/code&gt; prints a per-route table whose First Load JS column is the JavaScript a visitor downloads for that route, shared chunks included. &lt;code&gt;@next/bundle-analyzer&lt;/code&gt; run with &lt;code&gt;ANALYZE=true&lt;/code&gt; tells you which module moved it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not go looking for this. I was reading the route table at the end of a &lt;code&gt;next build&lt;/code&gt; and noticed that a marketing page with three paragraphs and a signup form carried almost the same First Load JS as the dashboard. Nothing on that page was heavy. The shared chunk was heavy, because one small client component imported &lt;code&gt;{ Button }&lt;/code&gt; from a first-party barrel that re-exported the entire component library, icons and charts included.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually ends up in a Next.js client bundle?
&lt;/h2&gt;

&lt;p&gt;Everything reachable from a &lt;code&gt;'use client'&lt;/code&gt; module ships to the browser, plus the React and Next.js runtime and the RSC payload for the route. Modules imported only by Server Components do not ship. That is the whole model, and it is why boundary placement matters more than any bundler flag.&lt;/p&gt;

&lt;p&gt;The failure mode is a component that is 95% static markup with one interactive control, marked &lt;code&gt;'use client'&lt;/code&gt; at the top of the file. The directive is contagious downward: the date library, the icon set, and the validation schema that file imports all become client code, even though only one button needs a click handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/dashboard/page.tsx — Server Component: these imports never reach the browser&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;buildReport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/report&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// stays on the server&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Chart&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./chart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// client leaf, below&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;buildReport&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Chart&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// app/dashboard/chart.tsx&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LineChart&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heavy-charts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// only this one ships&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail saves real bytes: TypeScript type imports are erased, but only if the compiler can tell they are types. Writing &lt;code&gt;import type { User } from './models'&lt;/code&gt; guarantees erasure. Turning on &lt;code&gt;verbatimModuleSyntax&lt;/code&gt; in &lt;code&gt;tsconfig.json&lt;/code&gt;, available since TypeScript 5.0, makes the rule explicit — a plain &lt;code&gt;import&lt;/code&gt; is emitted as a runtime import even when every binding it names is a type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do barrel files make bundles big and dev servers slow?
&lt;/h2&gt;

&lt;p&gt;A barrel file forces the bundler to resolve every module it re-exports, even when you use one symbol. In a production build the bundler can often tree-shake the unused ones back out; in development it does not bother, which is why a barrel-heavy app compiles slowly on every cold route request even though the shipped bundle looks fine.&lt;/p&gt;

&lt;p&gt;Tree shaking is also not guaranteed. It only happens when the bundler can prove a module has no side effects — no top-level code that mutates anything outside itself. A single re-exported module that registers a polyfill, patches a prototype, or imports a stylesheet at the top level pins itself into the output, and any module it imports comes along.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/index.ts — the barrel&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./modal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data-table&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ...and 197 more&lt;/span&gt;

&lt;span class="c1"&gt;// a client component&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// resolves 200 modules&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// resolves one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For first-party barrels inside an app, the reliable fix is the second line: import the module directly and delete the barrel, or keep the barrel only for the small stable set of things everything uses. An ESLint &lt;code&gt;no-restricted-imports&lt;/code&gt; rule that bans &lt;code&gt;@/components&lt;/code&gt; as an import source is what stopped the barrel from growing back in my repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does experimental.optimizePackageImports actually do?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;experimental.optimizePackageImports&lt;/code&gt; rewrites &lt;code&gt;import { X } from 'pkg'&lt;/code&gt; into an import of the specific file inside &lt;code&gt;pkg&lt;/code&gt; that defines &lt;code&gt;X&lt;/code&gt;, at build time, so the bundler never walks the package's barrel. Next.js applies it automatically to a maintained list of common offenders. Packages outside that list, including your own workspace packages in a monorepo, need to be named explicitly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&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;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;experimental&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;optimizePackageImports&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;@acme/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-icons&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;lodash-es&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;serverExternalPackages&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;sharp&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two limits are worth knowing before you rely on it. It works on package names, so an internal path alias like &lt;code&gt;@/components&lt;/code&gt; is not a candidate — that one you fix by deleting the barrel. And it cannot rewrite a namespace import: &lt;code&gt;import * as Icons from 'react-icons/fa'&lt;/code&gt; keeps the whole namespace live because any property access on it is only knowable at runtime. The older &lt;code&gt;modularizeImports&lt;/code&gt; option still exists for packages whose file layout needs a manual pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure client bundle size without fooling myself?
&lt;/h2&gt;

&lt;p&gt;Measure a production build and compare First Load JS per route between two builds of the same app. Development bundles are unminified, un-tree-shaken, and carry HMR machinery, so a number read from &lt;code&gt;next dev&lt;/code&gt; means nothing about what users download.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @next/bundle-analyzer
&lt;span class="nv"&gt;ANALYZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@next/bundle-analyzer&lt;/code&gt; wraps the Next.js config and writes separate treemap reports for the client, Node.js, and Edge bundles. Open the client report, switch the size metric from parsed to gzipped, and read the largest rectangles — parsed size misleads about what crosses the network. The rule: change one thing per build. Deep imports, a config flag, and a dynamic import landed together will tell you the total moved and nothing about which change earned it.&lt;/p&gt;

&lt;p&gt;I am deliberately not quoting my own before-and-after numbers, because a bundle size delta is a fact about one dependency tree and does not transfer to yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should I reach for?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it changes&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct module import&lt;/td&gt;
&lt;td&gt;Bundler resolves one file instead of a whole barrel&lt;/td&gt;
&lt;td&gt;The barrel is first-party or the package is not in the optimize list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;optimizePackageImports&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Named barrel imports rewritten to deep paths at build time&lt;/td&gt;
&lt;td&gt;A third-party package with a large barrel and many call sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move &lt;code&gt;'use client'&lt;/code&gt; down&lt;/td&gt;
&lt;td&gt;The imported subtree stops being client code at all&lt;/td&gt;
&lt;td&gt;A mostly-static component was marked client for one handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next/dynamic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defers bytes to a second request; total stays the same&lt;/td&gt;
&lt;td&gt;Code genuinely not needed for first paint or first interaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replace the dependency&lt;/td&gt;
&lt;td&gt;Removes the bytes outright&lt;/td&gt;
&lt;td&gt;The library duplicates a platform API such as &lt;code&gt;Intl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When should I use next/dynamic instead of shrinking an import?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;next/dynamic&lt;/code&gt; when the code is genuinely not needed for the first render — a rich text editor, a map, a charting library behind a tab. It does not make an application smaller in total; it moves bytes later, and a chunk fetched at click time is a chunk the user waits on, which shows up as a worse Interaction to Next Paint rather than a worse Largest Contentful Paint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/dynamic&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;Editor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./editor&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;ssr&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;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-64 animate-pulse rounded bg-muted"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the App Router, &lt;code&gt;ssr: false&lt;/code&gt; is only allowed inside a Client Component. Calling &lt;code&gt;dynamic()&lt;/code&gt; with &lt;code&gt;ssr: false&lt;/code&gt; from a Server Component is a build error, because a Server Component has no client render pass to opt out of. Always pass a &lt;code&gt;loading&lt;/code&gt; placeholder with the same height as the real component, or the deferred chunk buys you a layout shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the server bundle size matter too?
&lt;/h2&gt;

&lt;p&gt;It matters for cold starts, not for the network. A serverless function has to be loaded before it runs, and a large dependency graph makes that slower. &lt;code&gt;serverExternalPackages&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt; — the stable name since Next.js 15, previously &lt;code&gt;experimental.serverComponentsExternalPackages&lt;/code&gt; — tells Next.js not to bundle a package and to require it natively at runtime instead. Packages like &lt;code&gt;sharp&lt;/code&gt; and most database drivers belong there.&lt;/p&gt;

&lt;p&gt;The habit I ended up with is smaller than any of these techniques: read the route table on every build, and treat a jump in First Load JS the same way you treat a failing test. The barrel that cost me a shared chunk was added in a one-line pull request that nobody could have reviewed into suspicion. The build output would have flagged it the same day.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Turbopack tree-shake barrel files automatically, so this stops mattering?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; A production build removes many unused re-exports regardless of bundler, but tree shaking still cannot remove a module with top-level side effects, and development builds do not tree-shake at all. Deep imports remain the fix that works in both modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is First Load JS the same as the size of the page's JavaScript?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. First Load JS is the route's own chunk plus the shared chunks every route loads, which is why one bad import in a shared component raises the number on pages that never use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I set &lt;code&gt;"sideEffects": false&lt;/code&gt; in my own workspace package?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes, if no module in it does anything at import time. If any module imports CSS or registers a global, use the array form — for example &lt;code&gt;"sideEffects": ["*.css"]&lt;/code&gt; — so the bundler keeps those files and shakes the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Will deleting barrel files break my public package API?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; It will if consumers import from the package root, so publish an &lt;code&gt;exports&lt;/code&gt; map with subpath entries first and keep the root barrel for one deprecation cycle. Inside a private app there is no such contract, and the barrel can go immediately.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nextjs-bundle-size-barrel-files-optimizepackageimports-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nextjs-bundle-size-barrel-files-optimizepackageimports-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Focus Management in the Next.js App Router: Field Notes on the Route Change That Loses Focus, inert, and Native dialog</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Thu, 24 Sep 2026 06:00:13 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/focus-management-in-the-nextjs-app-router-field-notes-on-the-route-change-that-loses-focus-295d</link>
      <guid>https://dev.to/ahmed_mahmoud360/focus-management-in-the-nextjs-app-router-field-notes-on-the-route-change-that-loses-focus-295d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; The Next.js App Router announces client-side route changes to screen readers through its built-in route announcer, but it never moves keyboard focus. Focus is left on a link that React just unmounted, so the next Tab press restarts at the top of the document — and the fix is a fifteen-line client component plus one &lt;code&gt;tabIndex={-1}&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js injects a route announcer on every client-side navigation: a visually hidden &lt;code&gt;aria-live&lt;/code&gt; region that reads the new &lt;code&gt;document.title&lt;/code&gt;, falling back to the page's &lt;code&gt;h1&lt;/code&gt;. It handles announcing; it does not handle focus.&lt;/li&gt;
&lt;li&gt;A client-side navigation in the Next.js App Router does not reset keyboard focus. When the activated link unmounts, &lt;code&gt;document.activeElement&lt;/code&gt; falls back to &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, so the user's next Tab starts from the top of the page instead of the new content.&lt;/li&gt;
&lt;li&gt;The fix is a client component that watches &lt;code&gt;usePathname()&lt;/code&gt; from &lt;code&gt;next/navigation&lt;/code&gt; and calls &lt;code&gt;.focus({ preventScroll: true })&lt;/code&gt; on a container carrying &lt;code&gt;tabIndex={-1}&lt;/code&gt;. &lt;code&gt;tabIndex={-1}&lt;/code&gt; makes a non-interactive element focusable by script without adding it to the Tab order.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inert&lt;/code&gt; is a global HTML attribute that removes a subtree from the Tab order, the accessibility tree, and pointer hit testing in one declaration. &lt;code&gt;aria-hidden="true"&lt;/code&gt; removes it from the accessibility tree only and leaves every control inside it tabbable.&lt;/li&gt;
&lt;li&gt;The native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element traps focus and renders in the browser top layer only when opened imperatively with &lt;code&gt;showModal()&lt;/code&gt;. Rendering &lt;code&gt;&amp;lt;dialog open&amp;gt;&lt;/code&gt; in JSX produces a non-modal dialog with no focus trap and no backdrop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I found this the way most people find accessibility bugs: not with an audit tool, but by unplugging my mouse for an afternoon. Navigating my own dashboard with the keyboard alone, every link left me stranded — the page had changed, and Tab took me back to the site logo. Since the European Accessibility Act became applicable on 28 June 2025, that class of bug also stopped being purely a craft question for a lot of the products I work on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the Next.js App Router already do for screen readers on navigation?
&lt;/h2&gt;

&lt;p&gt;It announces the new page title, and that is all it does. Next.js renders a route announcer element into the DOM — a visually hidden region with &lt;code&gt;aria-live&lt;/code&gt; — and writes the new page name into it on each client-side navigation. It reads &lt;code&gt;document.title&lt;/code&gt; first and falls back to the text of the page's &lt;code&gt;h1&lt;/code&gt;. A screen reader user therefore hears that something changed.&lt;/p&gt;

&lt;p&gt;Two gaps are left for application code to close. The first is that the announcement is only as good as the title: a title resolved late by &lt;code&gt;generateMetadata&lt;/code&gt; in a streamed segment can land after the announcer fires, so what gets read may be the previous page's name. A distinct, statically resolved &lt;code&gt;title&lt;/code&gt; per route is the reliable version. The second gap is bigger, and it is focus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should keyboard focus go after a client-side route change?
&lt;/h2&gt;

&lt;p&gt;Focus belongs at the top of the new main content. A full browser navigation does this for free by resetting focus to the start of the document. A client-side navigation does not, because from the browser's point of view nothing navigated — React replaced some nodes. The link the user activated is one of those nodes, so focus falls to &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and sequential navigation restarts at the top of the page, walking the entire header again on every click.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;usePathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RouteFocus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePathname&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;// the browser already focused the document on load&lt;/span&gt;
    &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preventScroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;tabIndex&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in those fifteen lines earn their place. Skipping the first render matters because on initial load the browser has already put focus at the document start, and stealing it again is noise. &lt;code&gt;preventScroll: true&lt;/code&gt; matters because Next.js restores scroll position across navigations and an unguarded &lt;code&gt;.focus()&lt;/code&gt; call scrolls the container into view and fights that restoration. And &lt;code&gt;tabIndex={-1}&lt;/code&gt; is what makes a plain &lt;code&gt;div&lt;/code&gt; focusable by script while staying out of the Tab sequence; without it, &lt;code&gt;.focus()&lt;/code&gt; silently does nothing.&lt;/p&gt;

&lt;p&gt;I wrap the layout's main region rather than the page, so the focus target still exists while a &lt;code&gt;loading.tsx&lt;/code&gt; boundary is rendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"#main"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"skip-link"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Skip to content&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Nav&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RouteFocus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RouteFocus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skip link above it solves the other half of the same problem, and it has two requirements: it must be the first focusable element in the DOM, and it must become visible when focused. A skip link pointing at &lt;code&gt;#main&lt;/code&gt; only works if the target can receive focus — which is the &lt;code&gt;tabIndex={-1}&lt;/code&gt; rule again.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I trap focus in a modal without a focus-trap library?
&lt;/h2&gt;

&lt;p&gt;Use the native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element and open it with &lt;code&gt;showModal()&lt;/code&gt;. &lt;code&gt;HTMLDialogElement.showModal()&lt;/code&gt; puts the dialog in the browser's top layer, renders the &lt;code&gt;::backdrop&lt;/code&gt; pseudo-element, makes everything outside it inert, and closes on Escape by firing a &lt;code&gt;cancel&lt;/code&gt; event. That is most of the feature set focus-trap packages were written to reimplement in JavaScript.&lt;/p&gt;

&lt;p&gt;The trap comes from the method call, not from the markup. A &lt;code&gt;&amp;lt;dialog open&amp;gt;&lt;/code&gt; rendered declaratively is a non-modal dialog: no top layer, no backdrop, no focus trap. In React that means driving it from an effect rather than from JSX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Modal&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="nx"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;onClose&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="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDialogElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;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;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;open&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;el&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="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showModal&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// focus trap + top layer + ::backdrop&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;open&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;open&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;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="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;dialog&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onCancel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two behaviours still need code from me. Initial focus goes to the first focusable descendant, which is usually a close button, so I put &lt;code&gt;autoFocus&lt;/code&gt; on the element I actually want. And focus is not restored on close: I capture &lt;code&gt;document.activeElement&lt;/code&gt; before calling &lt;code&gt;showModal()&lt;/code&gt; and call &lt;code&gt;.focus()&lt;/code&gt; on it after &lt;code&gt;close()&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Traps focus&lt;/th&gt;
&lt;th&gt;Escape closes it&lt;/th&gt;
&lt;th&gt;Top layer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; opened with &lt;code&gt;showModal()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, in the browser&lt;/td&gt;
&lt;td&gt;Yes, fires &lt;code&gt;cancel&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, with &lt;code&gt;::backdrop&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;dialog open&amp;gt;&lt;/code&gt; written in JSX&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;popover&lt;/code&gt; attribute&lt;/td&gt;
&lt;td&gt;No, by design&lt;/td&gt;
&lt;td&gt;Yes, light dismiss&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;div role="dialog"&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only what you write&lt;/td&gt;
&lt;td&gt;Only what you write&lt;/td&gt;
&lt;td&gt;No, &lt;code&gt;z-index&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;popover&lt;/code&gt; attribute is the right choice for menus, tooltips and non-blocking panels precisely because it does not trap focus. Reaching for it to build a confirmation dialog is the mistake I now see most often, because both features shipped close enough together to feel interchangeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use inert instead of aria-hidden?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;inert&lt;/code&gt; when the user must not reach the content at all, and &lt;code&gt;aria-hidden&lt;/code&gt; only when the content is visible but redundant for assistive technology. They are not interchangeable. &lt;code&gt;aria-hidden="true"&lt;/code&gt; hides a subtree from the accessibility tree while leaving every button inside it tabbable, which produces the worst available state: focus lands on a control the screen reader will not describe.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inert&lt;/code&gt; is a single global HTML attribute that removes a subtree from the Tab order, the accessibility tree, and pointer hit testing at once. React 19 treats it as a real boolean prop, so &lt;code&gt;inert={isDrawerOpen}&lt;/code&gt; compiles to the right thing; React 18 expected a string and warned on a boolean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"app-shell"&lt;/span&gt; &lt;span class="na"&gt;inert&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isDrawerOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* every control in here is untabbable and invisible to screen readers */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog"&lt;/span&gt; &lt;span class="na"&gt;aria-modal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;drawer&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;aside&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;inert&lt;/code&gt; — background content behind a custom drawer, off-screen carousel slides, a form section that is temporarily unavailable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aria-hidden&lt;/code&gt; — decorative icons sitting next to a visible text label, content duplicated purely for layout.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;display: none&lt;/code&gt; — removes the element from layout and both trees; the correct default when content is genuinely not present.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;showModal()&lt;/code&gt; none of the three is needed: the browser applies inertness to everything outside the top layer itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I announce async updates like search results or form errors?
&lt;/h2&gt;

&lt;p&gt;Put the live region in the DOM before the content arrives, then change its text. A screen reader announces changes inside a region it was already observing, so mounting &lt;code&gt;&amp;lt;div aria-live="polite"&amp;gt;42 results&amp;lt;/div&amp;gt;&lt;/code&gt; at the same moment the results arrive typically announces nothing at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Always mounted. Only the text inside it changes. */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;aria-live&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sr-only"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&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;Searching&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="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; results`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the politeness level deliberately. &lt;code&gt;role="status"&lt;/code&gt; maps to &lt;code&gt;aria-live="polite"&lt;/code&gt; and waits for a pause in speech, which is correct for result counts, saved indicators and toasts. &lt;code&gt;role="alert"&lt;/code&gt; maps to &lt;code&gt;aria-live="assertive"&lt;/code&gt; and interrupts immediately, which is correct only for validation failures and errors the user must handle now.&lt;/p&gt;

&lt;p&gt;For form errors I skip the live region when I can. Moving focus to the first invalid input announces the message through that input's own &lt;code&gt;aria-describedby&lt;/code&gt;, and it puts the caret where the fix has to happen. A live region tells the user something is wrong; focus tells them where.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep keyboard accessibility from regressing in CI?
&lt;/h2&gt;

&lt;p&gt;Automated rule engines catch the markup mistakes and none of the focus mistakes. &lt;code&gt;@axe-core/playwright&lt;/code&gt; finds missing labels, contrast failures and invalid ARIA, but it cannot tell you that focus went to &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; after a route change, because that state is perfectly valid HTML. Focus needs explicit assertions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;route change moves focus to main&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="nx"&gt;page&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&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;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Projects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;main&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;closing a modal returns focus to its trigger&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="nx"&gt;page&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;trigger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Delete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escape&lt;/span&gt;&lt;span class="dl"&gt;'&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeFocused&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;Two tests, one per bug class, covering the two bugs keyboard users actually report. I run an axe scan over the main templates in the same suite for the markup half. Neither replaces the afternoon without a mouse — that is still the check that finds what no rule encodes.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Next.js move focus on a route change automatically?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. The App Router injects a route announcer that reads the new document title into an &lt;code&gt;aria-live&lt;/code&gt; region, but it does not change &lt;code&gt;document.activeElement&lt;/code&gt;. Moving focus is application code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should &lt;code&gt;tabIndex={-1}&lt;/code&gt; go on the &lt;code&gt;h1&lt;/code&gt; or on a wrapper element?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A wrapper around &lt;code&gt;main&lt;/code&gt; is the more robust target, because it survives pages that have no &lt;code&gt;h1&lt;/code&gt; or that stream one in late. Both are announced; the wrapper cannot go missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is &lt;code&gt;inert&lt;/code&gt; safe to ship in 2026?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. &lt;code&gt;inert&lt;/code&gt; is supported in current Chrome, Edge, Safari and Firefox and has been for several release cycles. React 19 accepts it as a boolean prop, while React 18 required a string value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need a focus-trap library?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not for modal dialogs, because &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; opened with &lt;code&gt;showModal()&lt;/code&gt; traps focus in the browser. A library is still useful for non-dialog patterns such as an embedded wizard step that must contain focus without entering the top layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does &lt;code&gt;aria-hidden&lt;/code&gt; stop an element from being focusable?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No, and that combination is a bug. &lt;code&gt;aria-hidden="true"&lt;/code&gt; removes an element from the accessibility tree while leaving it in the Tab order, so a keyboard user can focus a control no screen reader will describe. Use &lt;code&gt;inert&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;None of this required a dependency. A fifteen-line client component, one HTML attribute, one native element and two Playwright assertions covered every keyboard bug I could find in an afternoon of not touching my mouse. The App Router gives you the announcement for free. The focus is still yours to move.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nextjs-app-router-focus-management-accessibility-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nextjs-app-router-focus-management-accessibility-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>a11y</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Zero-Downtime Postgres Migrations: Field Notes on Expand/Contract, lock_timeout, and the ALTER TABLE That Queued Behind One Slow Query</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/zero-downtime-postgres-migrations-field-notes-on-expandcontract-locktimeout-and-the-alter-3d3m</link>
      <guid>https://dev.to/ahmed_mahmoud360/zero-downtime-postgres-migrations-field-notes-on-expandcontract-locktimeout-and-the-alter-3d3m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A Postgres migration usually takes an app down not because the DDL is slow, but because &lt;code&gt;ALTER TABLE&lt;/code&gt; waits for an ACCESS EXCLUSIVE lock and every query that arrives after it waits in the same lock queue. Setting a short &lt;code&gt;lock_timeout&lt;/code&gt;, splitting every breaking change into expand/backfill/contract deploys, and running migrations as their own pipeline step removes nearly all of that risk.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Postgres lock requests queue in arrival order. A pending &lt;code&gt;ALTER TABLE&lt;/code&gt; blocked by one long-running &lt;code&gt;SELECT&lt;/code&gt; blocks every read and write that arrives behind it, which is why an instant DDL statement can still cause minutes of downtime.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lock_timeout&lt;/code&gt; caps how long a statement waits to &lt;em&gt;acquire&lt;/em&gt; a lock; &lt;code&gt;statement_timeout&lt;/code&gt; caps how long it runs &lt;em&gt;after&lt;/em&gt; acquiring one. Setting &lt;code&gt;lock_timeout&lt;/code&gt; to a few seconds and retrying makes a migration fail fast instead of freezing traffic.&lt;/li&gt;
&lt;li&gt;Expand/contract is a three-deploy pattern: add the new schema alongside the old one, backfill and dual-write, then remove the old schema. It exists because a rolling deploy runs the old and new application versions against the same database at the same time.&lt;/li&gt;
&lt;li&gt;Since Postgres 11, &lt;code&gt;ADD COLUMN ... NOT NULL DEFAULT &amp;lt;constant&amp;gt;&lt;/code&gt; stores the default in the catalog and does not rewrite the table. A volatile default such as &lt;code&gt;gen_random_uuid()&lt;/code&gt; still rewrites every row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; does not block writes but cannot run inside a transaction block, so it needs its own migration step, and a failed run leaves an &lt;code&gt;INVALID&lt;/code&gt; index that must be dropped before retrying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration that taught me this added one nullable column. It was instant in staging. In production it hung, and the API stopped answering. Nothing about the column was slow — everything queued behind it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did a migration that runs instantly still take the site down?
&lt;/h2&gt;

&lt;p&gt;Because Postgres queues lock requests in arrival order, and a blocked &lt;code&gt;ALTER TABLE&lt;/code&gt; blocks everyone behind it. &lt;code&gt;ALTER TABLE&lt;/code&gt; needs an ACCESS EXCLUSIVE lock — the strongest lock level in Postgres, which conflicts with every other lock, including the ACCESS SHARE lock that a plain &lt;code&gt;SELECT&lt;/code&gt; takes. If any transaction is already holding a conflicting lock on that table, the &lt;code&gt;ALTER TABLE&lt;/code&gt; waits. Postgres does not let later, weaker requests jump the queue, so the next &lt;code&gt;SELECT&lt;/code&gt; waits behind the &lt;code&gt;ALTER TABLE&lt;/code&gt;, and so does every request after it.&lt;/p&gt;

&lt;p&gt;That turns a one-millisecond catalog update into an outage whose length equals the runtime of whatever was already holding the table. The usual culprits are the same three every time: a long analytics &lt;code&gt;SELECT&lt;/code&gt;, an &lt;code&gt;idle in transaction&lt;/code&gt; session that a web request opened and never committed, and autovacuum running in anti-wraparound mode, which does not yield the way ordinary autovacuum does.&lt;/p&gt;

&lt;p&gt;To see it live, join &lt;code&gt;pg_stat_activity&lt;/code&gt; against &lt;code&gt;pg_blocking_pids()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocked_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'idle'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any row whose &lt;code&gt;blocked_by&lt;/code&gt; array is non-empty is waiting. The PID listed inside that array is the transaction you actually need to deal with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the expand/contract migration pattern?
&lt;/h2&gt;

&lt;p&gt;Expand/contract is a three-deploy sequence that keeps the database compatible with both the old and the new version of the application at every moment. It is required for any rolling or serverless deploy, because during the rollout both versions are serving requests against one database.&lt;/p&gt;

&lt;p&gt;Renaming &lt;code&gt;users.full_name&lt;/code&gt; to &lt;code&gt;users.display_name&lt;/code&gt; with &lt;code&gt;ALTER TABLE ... RENAME COLUMN&lt;/code&gt; is instant metadata-only work in Postgres. It is also an outage: every old instance still running &lt;code&gt;SELECT full_name&lt;/code&gt; starts erroring the moment it commits. The same rename as expand/contract:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expand.&lt;/strong&gt; Add &lt;code&gt;display_name text&lt;/code&gt; as a nullable column. Deploy code that writes both columns and still reads &lt;code&gt;full_name&lt;/code&gt;. The old version keeps working because the new column is optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfill.&lt;/strong&gt; Copy existing rows in batches, then flip reads to &lt;code&gt;display_name&lt;/code&gt; and deploy. Both columns are now populated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract.&lt;/strong&gt; Stop writing &lt;code&gt;full_name&lt;/code&gt;, deploy, and only then run &lt;code&gt;ALTER TABLE users DROP COLUMN full_name;&lt;/code&gt;. Dropping a column is instant but irreversible in practice, so it goes last and alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule I follow: &lt;strong&gt;a deploy may add optional things or remove unused things, never both, and never anything a currently-running instance depends on.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Postgres DDL operations are safe, and which rewrite the whole table?
&lt;/h2&gt;

&lt;p&gt;A table rewrite copies every row into new files while holding ACCESS EXCLUSIVE, which on a large table is an outage of unbounded length. This is the cheat sheet I keep next to the migration folder (Postgres 12 and newer):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;What I do instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD COLUMN&lt;/code&gt; nullable, or constant &lt;code&gt;DEFAULT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Metadata only (PG 11+)&lt;/td&gt;
&lt;td&gt;Run it directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN ... DEFAULT gen_random_uuid()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full table rewrite&lt;/td&gt;
&lt;td&gt;Add nullable, backfill, then set the default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN TYPE varchar(50) -&amp;gt; text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metadata only (binary coercible)&lt;/td&gt;
&lt;td&gt;Run it directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN TYPE int -&amp;gt; bigint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full table rewrite&lt;/td&gt;
&lt;td&gt;New column, backfill, expand/contract swap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN ... SET NOT NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full scan under ACCESS EXCLUSIVE&lt;/td&gt;
&lt;td&gt;Validated &lt;code&gt;CHECK&lt;/code&gt; constraint first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD CONSTRAINT ... FOREIGN KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scans and locks both tables&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NOT VALID&lt;/code&gt;, then &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blocks writes for the whole build&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, own step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DROP COLUMN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metadata only, instant&lt;/td&gt;
&lt;td&gt;Safe for the DB, breaks old code — contract only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RENAME COLUMN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metadata only, instant&lt;/td&gt;
&lt;td&gt;Never in a live deploy — use expand/contract&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;NOT VALID&lt;/code&gt; trick is the one I reach for most. Adding a foreign key or check constraint normally scans the entire table while holding a strong lock. Splitting it in two moves the scan out from under that lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Step 1: instant. New writes are checked, existing rows are not scanned.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_user_fk&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Step 2: scans existing rows under SHARE UPDATE EXCLUSIVE, blocking neither reads nor writes.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_user_fk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape makes a column &lt;code&gt;NOT NULL&lt;/code&gt; without a blocking scan. Since Postgres 12, &lt;code&gt;SET NOT NULL&lt;/code&gt; can use an already-validated &lt;code&gt;CHECK&lt;/code&gt; constraint as proof and skip its own scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_display_name_nn&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_display_name_nn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- no scan, uses the validated CHECK&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_display_name_nn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I stop a migration from blocking traffic?
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;lock_timeout&lt;/code&gt; before the DDL so the migration gives up instead of parking itself at the head of the lock queue. This is the single highest-value line in my migration setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;lock_timeout = '3s'&lt;/code&gt;, a migration that cannot get its lock within three seconds fails with &lt;code&gt;canceling statement due to lock timeout&lt;/code&gt; (SQLSTATE &lt;code&gt;55P03&lt;/code&gt;) and releases the queue. Nothing behind it waits longer than three seconds. Failing is fine: the correct response is to retry, and the third or fourth attempt lands in a gap between long queries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-orm&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withLockRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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;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="nx"&gt;attempts&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SET lock_timeout = '3s'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;55P03&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;attempts&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;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&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="p"&gt;)&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;2&lt;/span&gt; &lt;span class="o"&gt;**&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;500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// back off, then try again&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;One caveat: &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot run inside a transaction block. Most migration runners wrap each file in a single transaction, so a concurrent index needs a step that skips that wrapper. A failed run leaves an &lt;code&gt;INVALID&lt;/code&gt; index behind, which &lt;code&gt;DROP INDEX CONCURRENTLY&lt;/code&gt; must remove before you retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I backfill millions of rows without holding one long transaction?
&lt;/h2&gt;

&lt;p&gt;Backfill in bounded batches, each in its own transaction, with a pause between them. A single &lt;code&gt;UPDATE&lt;/code&gt; across a large table holds row locks for its entire runtime, bloats the table with dead tuples faster than autovacuum reclaims them, and grows the WAL enough to make a replica fall behind.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- One batch. Run repeatedly until it reports 0 rows updated.&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;full_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; means the backfill steps over rows that live traffic is currently editing instead of waiting for them; the next pass picks them up. I drive the loop from a script rather than a migration file, so it can be paused, resumed, and monitored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;moved&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="k"&gt;for &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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BACKFILL_BATCH&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the SQL above&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&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="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;moved&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`backfilled &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;moved&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;await&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="p"&gt;)&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;200&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;    &lt;span class="c1"&gt;// let replicas and autovacuum catch up&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The right batch size is the one where a single batch stays well under your &lt;code&gt;statement_timeout&lt;/code&gt; and replication lag stays flat. I start at 5,000 rows and adjust from the actual lag graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should migrations run in a CI/CD pipeline?
&lt;/h2&gt;

&lt;p&gt;Migrations belong in a dedicated pipeline step that runs after the build and before the new version receives traffic — never inside &lt;code&gt;next build&lt;/code&gt;, never at application boot. Booting into a migration is the failure mode I see most in serverless projects: every cold-started instance migrates concurrently against the same database.&lt;/p&gt;

&lt;p&gt;Drizzle Kit and Prisma Migrate both take a Postgres advisory lock so concurrent runners serialize instead of colliding, which prevents corruption but not the deeper problem — the deploy has no clean place to stop if the migration fails. A separate step does:&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="c1"&gt;# .github/workflows/deploy.yml (abridged)&lt;/span&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;Migrate&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;pnpm drizzle-kit migrate&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DIRECT_DATABASE_URL }}&lt;/span&gt;   &lt;span class="c1"&gt;# direct connection, not the pooler&lt;/span&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;Deploy&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;vercel deploy --prod --prebuilt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details there matter. Migrations must use a direct database connection rather than a transaction-mode pooler, because session-level settings such as &lt;code&gt;SET lock_timeout&lt;/code&gt; and advisory locks misbehave when every statement can land on a different backend. And migrations are forward-only: expand/contract already guarantees the previous version runs fine against the new schema, which beats a &lt;code&gt;down&lt;/code&gt; script nobody has executed against production data.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need expand/contract just to add a column?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. A nullable column, or one with a constant default, is backward compatible: the old version ignores it. Expand/contract is only needed when something existing is renamed, dropped, retyped, or made mandatory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I run &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; from Drizzle or Prisma migrations?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, but only in a migration step that is not wrapped in a transaction, because Postgres rejects &lt;code&gt;CONCURRENTLY&lt;/code&gt; inside a transaction block. Keep it in its own file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What &lt;code&gt;lock_timeout&lt;/code&gt; value should I use?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A few seconds. Three seconds wins an ordinary lock race, and no queued request notices it. Pair it with retries and exponential backoff, because a lock timeout is an expected outcome, not an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I find out what blocked a migration after the fact?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Query &lt;code&gt;pg_stat_activity&lt;/code&gt; with &lt;code&gt;pg_blocking_pids()&lt;/code&gt; while it is happening, and enable &lt;code&gt;log_lock_waits&lt;/code&gt; so Postgres writes a log line whenever a session waits longer than &lt;code&gt;deadlock_timeout&lt;/code&gt; for a lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Are down migrations worth writing?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Rarely. Once a migration has run against production data, reverting it usually destroys data the forward version created. Expand/contract keeps every intermediate schema compatible with the adjacent application versions, which is what a rollback needs.&lt;/p&gt;

&lt;p&gt;None of this is exotic Postgres knowledge. It is four habits: a short &lt;code&gt;lock_timeout&lt;/code&gt;, additive-then-subtractive deploys, batched backfills, and migrations as their own pipeline step. I added them after one instant &lt;code&gt;ALTER TABLE&lt;/code&gt; cost me an outage, and I have not had a migration-caused incident since.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/zero-downtime-postgres-migrations-expand-contract-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/zero-downtime-postgres-migrations-expand-contract-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>node</category>
    </item>
    <item>
      <title>npm Supply-Chain Hardening in 2026: Lifecycle Scripts, minimumReleaseAge, and the Postinstall I Stopped Trusting</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 14 Sep 2026 06:00:09 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/npm-supply-chain-hardening-in-2026-lifecycle-scripts-minimumreleaseage-and-the-postinstall-i-52b5</link>
      <guid>https://dev.to/ahmed_mahmoud360/npm-supply-chain-hardening-in-2026-lifecycle-scripts-minimumreleaseage-and-the-postinstall-i-52b5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; The npm attacks that actually landed over the past two years executed at install time, through lifecycle scripts, within hours of the malicious version being published. Turning dependency lifecycle scripts off by default, refusing versions younger than a few days, and removing long-lived publish tokens from CI blocks most of that path without changing a line of application code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm lifecycle scripts (&lt;code&gt;preinstall&lt;/code&gt;, &lt;code&gt;install&lt;/code&gt;, &lt;code&gt;postinstall&lt;/code&gt;, &lt;code&gt;prepare&lt;/code&gt;) are shell commands declared inside a dependency's own &lt;code&gt;package.json&lt;/code&gt; and executed by your package manager, with your user's permissions, during &lt;code&gt;npm install&lt;/code&gt; — before any of your code runs.&lt;/li&gt;
&lt;li&gt;pnpm 10 stopped running dependency lifecycle scripts by default; a package that genuinely needs a build step must be listed in &lt;code&gt;onlyBuiltDependencies&lt;/code&gt;. npm has no such default, so I set &lt;code&gt;ignore-scripts=true&lt;/code&gt; in &lt;code&gt;.npmrc&lt;/code&gt; and allow-list explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;minimumReleaseAge&lt;/code&gt; is a cooldown setting — in pnpm, in Renovate, and as Dependabot's cooldown config — that refuses any version published less than N minutes ago. Compromised versions are usually detected and pulled within hours to a few days, so a cooldown absorbs exactly the window in which you would have installed one.&lt;/li&gt;
&lt;li&gt;npm provenance attestations prove which repository, commit, and CI workflow produced a tarball. They do not prove the code is benign: a compromised workflow emits perfectly valid provenance.&lt;/li&gt;
&lt;li&gt;Trusted publishing over OIDC removes the long-lived npm token from CI. That token is the credential the self-replicating npm worm of 2025 harvested from postinstall scripts in order to publish itself into the maintainer's other packages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually runs on my machine when I type npm install?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm install&lt;/code&gt; executes arbitrary code from your dependency tree before any of your own code runs. A lifecycle script is a command declared in a package's &lt;code&gt;scripts&lt;/code&gt; field that the package manager invokes automatically at install time — it inherits your shell environment, your &lt;code&gt;~/.npmrc&lt;/code&gt;, your SSH keys, and whatever cloud credentials sit on disk. There is no sandbox around it by default.&lt;/p&gt;

&lt;p&gt;The part I under-weighted for years is that this applies to the whole transitive tree, not just the dependencies I chose. Five direct dependencies routinely resolve to several hundred packages, and any one of them can declare a &lt;code&gt;postinstall&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first useful step is knowing how many packages in your tree claim that right:&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="c"&gt;# every package in the installed tree that declares an install-time script&lt;/span&gt;
npm query &lt;span class="s2"&gt;":attr(scripts, [postinstall])"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On pnpm 10 the equivalent is &lt;code&gt;pnpm approve-builds&lt;/code&gt;, which lists the packages whose build scripts were blocked and asks which ones to permit. The first time I ran either command on a mature repo, the list was shorter than I feared and less legitimate than I hoped — a handful of native modules that really do compile, and several pure-JavaScript packages running analytics pings.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop dependency lifecycle scripts from running?
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;ignore-scripts=true&lt;/code&gt; in the project's &lt;code&gt;.npmrc&lt;/code&gt; for npm and Yarn, or stay on pnpm 10's default of not running dependency scripts, then allow-list the few packages that genuinely need a build step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# .npmrc — checked into the repo, applies to every install here
&lt;/span&gt;&lt;span class="py"&gt;ignore-scripts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest cost: npm's &lt;code&gt;ignore-scripts&lt;/code&gt; is not selective. It also disables &lt;em&gt;your own&lt;/em&gt; package's scripts, so a repo relying on &lt;code&gt;prepare&lt;/code&gt; to install Husky hooks will silently stop doing that. I move those into an explicit &lt;code&gt;npm run setup&lt;/code&gt; step that the README and the CI job both call, and run &lt;code&gt;npm rebuild sharp better-sqlite3&lt;/code&gt; for native modules that need compilation.&lt;/p&gt;

&lt;p&gt;pnpm's version is narrower and better, because the allow-list is per package rather than all-or-nothing:&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;"pnpm"&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;"onlyBuiltDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"esbuild"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sharp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"better-sqlite3"&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 heuristic for that list: native addons and binary downloaders (&lt;code&gt;esbuild&lt;/code&gt;, &lt;code&gt;playwright&lt;/code&gt;) have a real reason to run at install. A pure-JavaScript utility asking for a &lt;code&gt;postinstall&lt;/code&gt; does not, and that request is itself the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is minimumReleaseAge, and why does a release cooldown stop worm-style attacks?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt; is a package-manager setting that refuses to install any version published more recently than a given number of minutes. pnpm reads it from &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, Renovate has supported it as a config option for years, and Dependabot exposes the same idea as a cooldown block in &lt;code&gt;dependabot.yml&lt;/code&gt;.&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="c1"&gt;# pnpm-workspace.yaml&lt;/span&gt;
&lt;span class="na"&gt;minimumReleaseAge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4320&lt;/span&gt;          &lt;span class="c1"&gt;# minutes; 3 days&lt;/span&gt;
&lt;span class="na"&gt;minimumReleaseAgeExclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@my-scope/*"&lt;/span&gt;                &lt;span class="c1"&gt;# our own packages publish and deploy same-day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;renovate.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimumReleaseAge"&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 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"internalChecksFilter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"strict"&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;A cooldown is disproportionately effective because a malicious npm version has a short shelf life. Registry maintainers, scanners, and other developers find these within hours to a couple of days, and the version gets unpublished or deprecated. The cooldown does not make you smarter than the attacker; it makes you later than the people who find them, which is the same outcome for a fraction of the effort.&lt;/p&gt;

&lt;p&gt;The trade-off is real: you are deliberately three days behind on &lt;em&gt;everything&lt;/em&gt;, including genuine security patches. I keep a documented override — a one-line config exclusion plus a PR that says why — so consciously pulling a same-day CVE fix stays possible and stays visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does npm provenance prove a package is safe?
&lt;/h2&gt;

&lt;p&gt;No. npm provenance is a signed attestation, generated by &lt;code&gt;npm publish --provenance&lt;/code&gt; running in a supported CI provider, that links a published tarball to the source repository, commit, and workflow that built it. It proves origin, not intent.&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="c"&gt;# verify registry signatures and attestations for the installed tree&lt;/span&gt;
npm audit signatures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What provenance buys me: a stolen laptop token can no longer publish a tarball that claims to come from our repo and carries a valid attestation, and I can diff a published artifact against the commit it names. What it does not cover: a maintainer who merges a malicious commit, a compromised build workflow, or the several hundred transitive packages that publish no attestation at all.&lt;/p&gt;

&lt;p&gt;The complementary control is trusted publishing, where npm exchanges a short-lived OIDC token from the CI run instead of reading a long-lived secret. Token theft is what turned the 2025 npm worm from an incident into a spreading one: its postinstall payload scraped credentials and used them to publish itself into whatever else that maintainer owned. A publish flow with no long-lived token in the environment has nothing to scrape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should CI change to reduce the blast radius of a bad install?
&lt;/h2&gt;

&lt;p&gt;Assume one install will eventually execute hostile code, and make that install a boring target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;npm ci&lt;/code&gt;, never &lt;code&gt;npm install&lt;/code&gt;, in CI.&lt;/strong&gt; &lt;code&gt;npm ci&lt;/code&gt; installs strictly from &lt;code&gt;package-lock.json&lt;/code&gt; and fails when the lockfile and &lt;code&gt;package.json&lt;/code&gt; disagree, removing silent transitive drift between the version a human reviewed and the version CI resolved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin GitHub Actions to a full commit SHA, not a tag.&lt;/strong&gt; Git tags are mutable, and the 2025 &lt;code&gt;tj-actions/changed-files&lt;/code&gt; compromise worked by repointing existing tags at a malicious commit, so everyone pinned to &lt;code&gt;@v4&lt;/code&gt; picked it up automatically.&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683&lt;/span&gt; &lt;span class="c1"&gt;# v4.2.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Keep secrets out of the job that installs dependencies.&lt;/strong&gt; The install step needs no publish token, no cloud key, no deploy credential. Splitting install/build from publish/deploy into separate jobs, with &lt;code&gt;permissions: contents: read&lt;/code&gt; on the first, means an install-time payload runs in a job holding nothing worth stealing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit the lockfile and review its diff.&lt;/strong&gt; The &lt;code&gt;integrity&lt;/code&gt; hash pins bytes, so the lockfile diff is the one place a surprise transitive bump is actually visible to a reviewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which hardening step buys the most safety per unit of pain?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What it stops&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ignore-scripts&lt;/code&gt; / &lt;code&gt;onlyBuiltDependencies&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Install-time code execution from any package in the tree&lt;/td&gt;
&lt;td&gt;Native modules break until allow-listed; npm's flag also disables your own scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;minimumReleaseAge&lt;/code&gt; cooldown&lt;/td&gt;
&lt;td&gt;Freshly published compromised versions, the dominant attack shape&lt;/td&gt;
&lt;td&gt;You run N days behind on everything, security patches included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;npm ci&lt;/code&gt; + committed lockfile&lt;/td&gt;
&lt;td&gt;Unreviewed transitive drift between review and deploy&lt;/td&gt;
&lt;td&gt;Effectively none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions pinned to commit SHA&lt;/td&gt;
&lt;td&gt;Mutable-tag hijack of your CI pipeline&lt;/td&gt;
&lt;td&gt;Bump churn; let Renovate update the SHAs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trusted publishing (OIDC)&lt;/td&gt;
&lt;td&gt;Token theft, and therefore worm-style self-replication&lt;/td&gt;
&lt;td&gt;One-time setup; publishing must happen from CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provenance + &lt;code&gt;npm audit signatures&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tarballs not built from the source they claim&lt;/td&gt;
&lt;td&gt;Does not stop malicious source; ecosystem coverage is partial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If I could ship only one of these to a team tomorrow, it would be the cooldown: no allow-list maintenance, no developer behaviour change. Disabling lifecycle scripts is strictly stronger but has a first-week cost while you discover which packages actually needed them.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Will &lt;code&gt;ignore-scripts=true&lt;/code&gt; break my project?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; It can, because npm's &lt;code&gt;ignore-scripts&lt;/code&gt; also disables your own package's lifecycle scripts such as &lt;code&gt;prepare&lt;/code&gt;, which is how Husky installs git hooks. Move those into an explicit &lt;code&gt;npm run setup&lt;/code&gt; step and run &lt;code&gt;npm rebuild &amp;lt;package&amp;gt;&lt;/code&gt; for native modules that need compiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does pnpm block install scripts by default?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. pnpm 10 does not run dependency lifecycle scripts unless the package is listed in &lt;code&gt;onlyBuiltDependencies&lt;/code&gt;, and &lt;code&gt;pnpm approve-builds&lt;/code&gt; shows which packages were blocked so you can permit them individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How long should a release-age cooldown be?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Three to seven days covers the detection window for most publicly disclosed npm compromises. Keep a documented override path so an urgent security patch can still be pulled the same day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Isn't &lt;code&gt;npm audit&lt;/code&gt; already covering this?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. &lt;code&gt;npm audit&lt;/code&gt; reports known CVEs from published advisories, and a malicious version has no advisory at the moment you would install it. &lt;code&gt;npm audit signatures&lt;/code&gt; is a different check that verifies registry signatures and provenance attestations; the two are complementary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does the lockfile's integrity hash protect me from a malicious package?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; The &lt;code&gt;integrity&lt;/code&gt; field is a subresource hash guaranteeing you received the same bytes as when the lockfile was written. It protects against tampering between the registry and you; it says nothing about whether those bytes were malicious when published.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/npm-supply-chain-hardening-lifecycle-scripts-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/npm-supply-chain-hardening-lifecycle-scripts-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>npm</category>
      <category>security</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Hybrid Search in Postgres with pgvector: Field Notes on HNSW, tsvector, and Why Pure Vector Search Missed Exact Matches</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:27:33 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/hybrid-search-in-postgres-with-pgvector-field-notes-on-hnsw-tsvector-and-why-pure-vector-search-1a18</link>
      <guid>https://dev.to/ahmed_mahmoud360/hybrid-search-in-postgres-with-pgvector-field-notes-on-hnsw-tsvector-and-why-pure-vector-search-1a18</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; pgvector is a Postgres extension that adds vector column types and approximate-nearest-neighbour indexes, and used alone it is a mediocre search engine. Every retrieval bug I shipped in a RAG feature was fixed by fusing pgvector similarity with Postgres full-text search through Reciprocal Rank Fusion — not by buying a vector database and not by swapping the embedding model.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My first retrieval pipeline was one &lt;code&gt;ORDER BY embedding &amp;lt;=&amp;gt; $1 LIMIT 5&lt;/code&gt; and a prompt. It demoed well, then failed on the queries people actually type: exact error codes, function names, invoice numbers, product SKUs. Cosine similarity is perfectly happy to return five paragraphs that are &lt;em&gt;about&lt;/em&gt; billing when the user typed a literal invoice ID that appears verbatim in exactly one row.&lt;/p&gt;

&lt;p&gt;These are the notes from moving that pipeline to hybrid search on Postgres 17 with pgvector 0.8, kept in the same database as the application data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgvector 0.8.0 adds four vector types (&lt;code&gt;vector&lt;/code&gt;, &lt;code&gt;halfvec&lt;/code&gt;, &lt;code&gt;bit&lt;/code&gt;, &lt;code&gt;sparsevec&lt;/code&gt;) and two ANN index types (HNSW and IVFFlat) to Postgres.&lt;/strong&gt; It does not add keyword matching, reranking, or chunking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure vector search fails on exact identifiers.&lt;/strong&gt; An embedding encodes meaning, so a literal token like &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt; holds no privileged position in the vector space, while Postgres full-text search matches it exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reciprocal Rank Fusion combines both retrieval arms without normalising scores.&lt;/strong&gt; RRF scores each row as the sum of &lt;code&gt;1 / (k + rank)&lt;/code&gt; across arms, with &lt;code&gt;k = 60&lt;/code&gt; as the common default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HNSW is the default index and IVFFlat is the exception.&lt;/strong&gt; HNSW builds on an empty table; IVFFlat must be created after the table holds representative rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;WHERE&lt;/code&gt; clause on an HNSW query can return fewer rows than the &lt;code&gt;LIMIT&lt;/code&gt; asks for.&lt;/strong&gt; The &lt;code&gt;hnsw.iterative_scan&lt;/code&gt; setting added in pgvector 0.8.0 is the fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does pgvector actually add to Postgres?
&lt;/h2&gt;

&lt;p&gt;pgvector is a Postgres extension that adds vector column types, distance operators, and approximate-nearest-neighbour indexes to an ordinary Postgres database. You enable it with &lt;code&gt;CREATE EXTENSION vector;&lt;/code&gt; and you get four storage types: &lt;code&gt;vector&lt;/code&gt; (4-byte floats), &lt;code&gt;halfvec&lt;/code&gt; (2-byte floats, added in pgvector 0.7.0), &lt;code&gt;bit&lt;/code&gt; (binary quantisation), and &lt;code&gt;sparsevec&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The distance operators are worth memorising, because choosing the wrong one silently degrades ranking instead of throwing: &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is cosine distance, &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; is L2 distance, &lt;code&gt;&amp;lt;#&amp;gt;&lt;/code&gt; is negative inner product, and &lt;code&gt;&amp;lt;+&amp;gt;&lt;/code&gt; is L1 distance. OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; and most hosted models return normalised vectors, so I use &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; with a &lt;code&gt;vector_cosine_ops&lt;/code&gt; index and stop thinking about it.&lt;/p&gt;

&lt;p&gt;The index operator class must match the query operator. An HNSW index built with &lt;code&gt;vector_l2_ops&lt;/code&gt; is simply not used by a query ordering on &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt;, and Postgres falls back to a sequential scan without complaining. My first "pgvector is slow" investigation was exactly that mismatch, visible in one &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;document_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt;   &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;tsv&lt;/span&gt;         &lt;span class="n"&gt;tsvector&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;chunks_embedding_hnsw&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;chunks_tsv_gin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tsv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What pgvector does not give you: tokenisation, keyword matching, reranking, chunking, or query rewriting. It is a similarity index, not a search product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did pure vector search miss exact matches?
&lt;/h2&gt;

&lt;p&gt;Pure vector search misses exact matches because an embedding encodes meaning rather than literal tokens. When a support agent searches for &lt;code&gt;ERR_MODULE_NOT_FOUND&lt;/code&gt;, the embedding of that string lands near "module", "import", and "error" in general — near enough to rank a generic troubleshooting page above the one paragraph containing the code verbatim.&lt;/p&gt;

&lt;p&gt;Three query shapes broke consistently for me: exact error codes, product SKUs and order numbers, and rare proper nouns such as a customer's company name. All three share a property: the useful signal is a low-frequency token, and averaging it into a 1536-dimension chunk embedding dilutes it into noise.&lt;/p&gt;

&lt;p&gt;Chunk size makes it worse. A 1,500-token chunk produces one vector representing the average of everything in it, so a single decisive sentence barely moves the vector. Cutting chunks to roughly 200–400 tokens with a small overlap improved my retrieval more than any embedding-model change I tried.&lt;/p&gt;

&lt;p&gt;Postgres full-text search has the opposite failure mode. &lt;code&gt;websearch_to_tsquery('english', 'how do I stop being billed')&lt;/code&gt; will not match a document titled "Subscription termination", because no shared stem exists. That complementary failure is the entire argument for hybrid search.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I combine full-text and vector search in one SQL query?
&lt;/h2&gt;

&lt;p&gt;Run both searches as separate CTEs, rank each independently, then fuse the ranks with Reciprocal Rank Fusion in a single query. RRF ignores raw scores and uses only position, which is why it needs no tuning: cosine distance lives on roughly 0 to 2, while &lt;code&gt;ts_rank_cd&lt;/code&gt; is unbounded, and weighting them directly turns into a magic-constant hunt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reciprocal Rank Fusion&lt;/strong&gt; assigns each row a score of &lt;code&gt;1 / (k + rank)&lt;/code&gt; in every arm it appears in, then sums those scores. &lt;code&gt;k = 60&lt;/code&gt; is the value from the original RRF paper and the one I have never needed to change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;keyword&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tsv&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ts_rank_cd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tsv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;  &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. The &lt;code&gt;LEFT JOIN&lt;/code&gt; plus &lt;code&gt;COALESCE&lt;/code&gt; pattern lets a row win by being strong in one arm alone — that is how the exact SKU match survives even though its embedding ranks nowhere. And &lt;code&gt;websearch_to_tsquery&lt;/code&gt; is the right parser for user input: it accepts quoted phrases and &lt;code&gt;-exclusions&lt;/code&gt; and never throws a syntax error on hostile input the way &lt;code&gt;to_tsquery&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;I retrieve 50 per arm and return 10. Retrieving 5 per arm produces two disjoint lists and fusion has nothing to fuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  HNSW or IVFFlat: which pgvector index should I build?
&lt;/h2&gt;

&lt;p&gt;Build HNSW unless index build time or memory is the binding constraint.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;HNSW&lt;/th&gt;
&lt;th&gt;IVFFlat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build on empty table&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — needs representative rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build parameters&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;m&lt;/code&gt;, &lt;code&gt;ef_construction&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lists&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query-time knob&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hnsw.ef_search&lt;/code&gt; (default 40)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ivfflat.probes&lt;/code&gt; (default 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build time&lt;/td&gt;
&lt;td&gt;Slower&lt;/td&gt;
&lt;td&gt;Faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index size / memory&lt;/td&gt;
&lt;td&gt;Larger&lt;/td&gt;
&lt;td&gt;Smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall per unit latency&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;td&gt;Lower at equal latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy inserts&lt;/td&gt;
&lt;td&gt;Handled incrementally&lt;/td&gt;
&lt;td&gt;Degrades; needs rebuilds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The knob that actually changes results is &lt;code&gt;hnsw.ef_search&lt;/code&gt;, which controls how many candidates the graph traversal keeps. Set it per transaction so a background reindexing job and a user-facing query can use different values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- hybrid query here&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why does adding a WHERE filter return fewer rows than my LIMIT?
&lt;/h2&gt;

&lt;p&gt;An HNSW query with a &lt;code&gt;WHERE&lt;/code&gt; clause can return fewer rows than the &lt;code&gt;LIMIT&lt;/code&gt; requests because the index traversal collects &lt;code&gt;ef_search&lt;/code&gt; candidates first and the filter is applied afterwards. Ask for 10 chunks belonging to one tenant that owns 0.1% of the table, and most of the 40 default candidates get discarded.&lt;/p&gt;

&lt;p&gt;This bit me on a multi-tenant knowledge base, and it is the worst class of bug: no error, no slow query, just quietly thinner context arriving at the model.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable iterative scans.&lt;/strong&gt; pgvector 0.8.0 added &lt;code&gt;hnsw.iterative_scan&lt;/code&gt;, which keeps scanning until enough rows survive the filter. Use &lt;code&gt;relaxed_order&lt;/code&gt; for throughput, &lt;code&gt;strict_order&lt;/code&gt; when exact distance order matters, and cap work with &lt;code&gt;hnsw.max_scan_tuples&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a partial index&lt;/strong&gt; when the filter is a small fixed set of values, e.g. &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise &lt;code&gt;ef_search&lt;/code&gt;&lt;/strong&gt; as a blunt instrument when the filter is mildly selective.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'relaxed_order'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_scan_tuples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What broke in production?
&lt;/h2&gt;

&lt;p&gt;The dimension mismatch is the first error everyone meets: &lt;code&gt;ERROR: expected 1536 dimensions, not 768&lt;/code&gt;. A &lt;code&gt;vector(1536)&lt;/code&gt; column is a hard constraint, so switching embedding models is a migration, not a config change. Embeddings from different models are not comparable at all — a model swap means a new column, a full backfill, and a cutover, never a partially re-embedded table.&lt;/p&gt;

&lt;p&gt;The 2000-dimension index limit surprised me more. pgvector indexes &lt;code&gt;vector&lt;/code&gt; columns up to 2,000 dimensions, so &lt;code&gt;text-embedding-3-large&lt;/code&gt; at 3,072 dimensions cannot be indexed as a plain &lt;code&gt;vector&lt;/code&gt;. Two honest options: store it as &lt;code&gt;halfvec(3072)&lt;/code&gt; and index with &lt;code&gt;halfvec_cosine_ops&lt;/code&gt;, which pgvector indexes up to 4,000 dimensions and which also halves storage; or request fewer dimensions from the API using the &lt;code&gt;dimensions&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;Index builds were slower than expected on a few hundred thousand rows, because the default &lt;code&gt;maintenance_work_mem&lt;/code&gt; is far too small for a graph index. Raising &lt;code&gt;maintenance_work_mem&lt;/code&gt; and &lt;code&gt;max_parallel_maintenance_workers&lt;/code&gt; turned an overnight build into a deploy-window build.&lt;/p&gt;

&lt;p&gt;Row width is the quiet one. A &lt;code&gt;vector(1536)&lt;/code&gt; value occupies roughly 6 KB, well past the point where Postgres moves the column out of line into TOAST storage, so every row fetch becomes an extra read. Keeping the chunk table narrow and joining to document metadata was worth more than any query rewrite.&lt;/p&gt;

&lt;p&gt;Finally, the latency I chased in SQL was not in SQL. Embedding the user's query is a network round trip on every search, and it dominated my P95 long before Postgres did.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need a dedicated vector database instead of pgvector?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not for application-scale retrieval in the low millions of chunks. Keeping vectors in Postgres means one backup story, one connection pool, transactional consistency between documents and embeddings, and the ability to join retrieval results against permissions in the same query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Which distance operator should I use with OpenAI embeddings?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Cosine distance, the &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; operator, with an HNSW index created using &lt;code&gt;vector_cosine_ops&lt;/code&gt;. The operator class must match the &lt;code&gt;ORDER BY&lt;/code&gt; operator or Postgres silently falls back to a sequential scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does hybrid search require two embeddings per chunk?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. It needs one &lt;code&gt;vector&lt;/code&gt; column for semantic similarity and one &lt;code&gt;tsvector&lt;/code&gt; column for lexical matching. Write the &lt;code&gt;tsvector&lt;/code&gt; as a &lt;code&gt;GENERATED ALWAYS AS ... STORED&lt;/code&gt; column so it can never drift from the body text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How many results should I retrieve before passing them to the model?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Around 50 candidates per arm, fused with RRF, then the top 5 to 10 fused chunks to the model. Feeding more usually lowers answer quality, because irrelevant context competes with relevant context inside the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I add a reranking step on top of hybrid search?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Only after hybrid search is in place and you can measure that the right chunk is retrieved but ranked too low. Reranking cannot recover a chunk that retrieval never returned, so fixing recall first is the higher-leverage move.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/postgres-pgvector-hybrid-search-rag-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/postgres-pgvector-hybrid-search-rag-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Web Workers in the Next.js App Router: Field Notes on import.meta.url, DataCloneError, and What a Worker Cannot Fix</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:00:11 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/web-workers-in-the-nextjs-app-router-field-notes-on-importmetaurl-datacloneerror-and-what-a-ood</link>
      <guid>https://dev.to/ahmed_mahmoud360/web-workers-in-the-nextjs-app-router-field-notes-on-importmetaurl-datacloneerror-and-what-a-ood</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A Web Worker is a background JavaScript thread with no DOM access, and it only improves responsiveness when the bottleneck is your own CPU-bound code — not React rendering. I moved three features off the main thread in a Next.js App Router app, and every bug I hit came from serialization, bundler paths, or React StrictMode spawning a second worker I never terminated.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had a client-side CSV importer that froze the tab for several seconds on large files. My first instinct was to memoize harder. That was wrong: the freeze was one long task inside &lt;em&gt;my own&lt;/em&gt; parsing code, and no amount of &lt;code&gt;useMemo&lt;/code&gt; moves work off the thread it already runs on. A Web Worker does.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Web Worker&lt;/strong&gt; is a separate JavaScript thread the browser runs alongside the main thread, with its own global scope, no DOM access, and communication only through message passing. I have since moved three features into workers in a Next.js 16 App Router app — CSV parsing, client-side image resizing, and a fuzzy search index. These are the notes I wish I had on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Web Worker only helps when the long task is your own CPU-bound JavaScript.&lt;/strong&gt; If the browser's Performance panel attributes the long task to React rendering, commit, or style recalculation, a worker moves nothing, because rendering must stay on the main thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always construct workers with &lt;code&gt;new Worker(new URL('./parse.worker.ts', import.meta.url))&lt;/code&gt;.&lt;/strong&gt; Both webpack 5 and Turbopack detect that exact expression and emit the worker as its own hashed chunk; a plain string path resolves to a 404 in a production build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;postMessage&lt;/code&gt; serializes with the structured clone algorithm, which drops functions, class prototypes, DOM nodes, and getters.&lt;/strong&gt; Sending any of those throws &lt;code&gt;DataCloneError&lt;/code&gt;, and sending a class instance silently produces a plain object with no methods on the other side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comlink turns &lt;code&gt;postMessage&lt;/code&gt; into awaited method calls&lt;/strong&gt; using a JavaScript Proxy, so the worker exposes an object and the page calls &lt;code&gt;await api.parse(file)&lt;/code&gt;. It costs roughly 1 KB gzipped and removes all request-ID bookkeeping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React StrictMode runs effects twice in development, so an uncleaned worker leaks a second thread.&lt;/strong&gt; Return &lt;code&gt;() =&amp;gt; worker.terminate()&lt;/code&gt; from the effect; without it a hot-reloading page accumulates workers until the tab is slower than before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does a Web Worker actually fix, and what does it not?
&lt;/h2&gt;

&lt;p&gt;A Web Worker fixes main-thread blocking caused by long-running synchronous JavaScript that does not touch the DOM. Parsing, compressing, diffing, hashing, tokenizing, building a search index, decoding a large payload, and running a WebAssembly module all move cleanly.&lt;/p&gt;

&lt;p&gt;A Web Worker does not fix slow React rendering. React's reconciliation and commit phases must run on the main thread because they write to the DOM. If an Interaction to Next Paint (INP) regression comes from rendering a 5,000-row table, the fix is virtualization or fewer components, not a worker. I wasted an afternoon before I accepted that.&lt;/p&gt;

&lt;p&gt;The distinction is visible in a Performance trace: expand the long task and read the flame chart. If the widest frames carry your own function names, a worker will help. If they are React internals or &lt;code&gt;Recalculate Style&lt;/code&gt;, it will not.&lt;/p&gt;

&lt;p&gt;Workers also have no access to &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, or &lt;code&gt;localStorage&lt;/code&gt;. They do get &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;IndexedDB&lt;/code&gt;, &lt;code&gt;WebAssembly&lt;/code&gt;, &lt;code&gt;crypto.subtle&lt;/code&gt;, &lt;code&gt;OffscreenCanvas&lt;/code&gt;, and timers. The rough rule I use now: if the code would run unchanged in Node.js, it will run in a worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I create a Web Worker in the Next.js App Router?
&lt;/h2&gt;

&lt;p&gt;Create the worker inside a &lt;code&gt;useEffect&lt;/code&gt; in a Client Component, using &lt;code&gt;new URL(..., import.meta.url)&lt;/code&gt;, and terminate it in the cleanup function. The &lt;code&gt;Worker&lt;/code&gt; constructor does not exist in Node.js, so constructing one during server rendering or at module scope throws &lt;code&gt;ReferenceError: Worker is not defined&lt;/code&gt; at build time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CsvImporter&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;workerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Worker&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../workers/parse.worker.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;parsed&lt;/span&gt;&lt;span class="dl"&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;workerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Three details matter. The &lt;code&gt;new URL('./x.worker.ts', import.meta.url)&lt;/code&gt; expression must appear literally inside the &lt;code&gt;Worker&lt;/code&gt; constructor — hoisting it into a variable defeats the bundler's static analysis and the file is never emitted. The &lt;code&gt;{ type: 'module' }&lt;/code&gt; option is what lets the worker use &lt;code&gt;import&lt;/code&gt; statements. And the worker file belongs outside &lt;code&gt;app/&lt;/code&gt; — I keep mine in &lt;code&gt;src/workers/&lt;/code&gt; — so the router never tries to interpret it as a route.&lt;/p&gt;

&lt;p&gt;The worker file itself is an ordinary module that listens on its own global scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/workers/parse.worker.ts&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;MessageEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="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;rows&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trailing &lt;code&gt;export {}&lt;/code&gt; is not decorative. It makes the file a module so TypeScript scopes &lt;code&gt;self&lt;/code&gt; to the worker context instead of colliding with the DOM lib's global declarations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my worker throw DataCloneError?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;postMessage&lt;/code&gt; serializes its argument with the &lt;strong&gt;structured clone algorithm&lt;/strong&gt;, which copies plain data but refuses functions, Symbols, DOM nodes, and anything holding a closure. Passing one of those throws &lt;code&gt;DataCloneError: Failed to execute 'postMessage'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The quieter failure is class instances. Structured clone copies own enumerable properties and discards the prototype, so a &lt;code&gt;Decimal&lt;/code&gt; or a parser instance arrives as a plain object with no methods, and the first method call blows up far from the &lt;code&gt;postMessage&lt;/code&gt; line that caused it. I now send only JSON-shaped data across the boundary and rehydrate on the receiving side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transferable objects&lt;/strong&gt; are the escape hatch for large payloads. An &lt;code&gt;ArrayBuffer&lt;/code&gt;, &lt;code&gt;MessagePort&lt;/code&gt;, &lt;code&gt;ImageBitmap&lt;/code&gt;, &lt;code&gt;OffscreenCanvas&lt;/code&gt;, or &lt;code&gt;ReadableStream&lt;/code&gt; can be transferred instead of copied, which hands ownership to the other thread in constant time and leaves the original detached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// buffer.byteLength is now 0 on this thread — ownership moved&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forgetting that second argument is the difference between moving a pointer and copying every byte. For the image-resize worker this was the single change that made the interaction feel instant, because a multi-megabyte copy was happening on the main thread before the worker ever started.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use Comlink instead of raw postMessage?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Comlink&lt;/strong&gt; is a roughly 1 KB library from the Chrome team that wraps &lt;code&gt;postMessage&lt;/code&gt; in a JavaScript Proxy so the worker looks like an awaitable object. Reach for it as soon as the worker has more than one operation, because hand-rolled routing means inventing request IDs, a pending-promise map, and a discriminated union of message types — then maintaining all three.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/workers/search.worker.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;comlink&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;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;buildIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Doc&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;term&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&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;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SearchApi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;comlink&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SearchApi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../workers/search.worker&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;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../workers/search.worker.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wrap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SearchApi&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&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.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two Comlink rules I learned by breaking them. Callbacks must be wrapped as &lt;code&gt;Comlink.proxy(cb)&lt;/code&gt;, because a bare function cannot be cloned. And transferables need &lt;code&gt;Comlink.transfer(value, [buffer])&lt;/code&gt; — Comlink will not infer a transfer list, so the zero-copy win silently disappears if you skip it.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;import type&lt;/code&gt; for &lt;code&gt;SearchApi&lt;/code&gt; so the worker module is never pulled into the main bundle, while the main thread still gets full autocomplete with every return type wrapped in a Promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use a Web Worker or scheduler.yield()?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;scheduler.yield()&lt;/code&gt; when the work must touch the DOM or is only moderately long; use a Web Worker when the work is pure computation measured in hundreds of milliseconds. &lt;code&gt;scheduler.yield()&lt;/code&gt; is a Scheduling API method that returns a Promise and lets the browser service pending input before the same function continues — it splits one long task into several short ones on the same thread rather than moving the work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// touches the DOM, must stay on the main thread&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;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Web Worker&lt;/th&gt;
&lt;th&gt;scheduler.yield()&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Thread&lt;/td&gt;
&lt;td&gt;Separate thread; main thread stays free&lt;/td&gt;
&lt;td&gt;Same thread; task split into chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM access&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;Spawn a thread and parse a second bundle&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;await&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data cost&lt;/td&gt;
&lt;td&gt;Structured clone unless transferred&lt;/td&gt;
&lt;td&gt;Zero — same memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Parsing, hashing, indexing, WebAssembly&lt;/td&gt;
&lt;td&gt;Long loops that build or mutate UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The setup cost is real: spawning a worker means the browser creates a thread and parses a second bundle. For work measured in a couple of milliseconds, the round trip is pure overhead. I only move something into a worker once I can see it as a long task — over 50 ms — in a trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke for me the first time?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;StrictMode spawned two workers.&lt;/strong&gt; React StrictMode mounts, unmounts, and remounts every component in development. My effect created a worker and returned nothing, so each hot reload left an orphaned thread holding its index in memory. Returning &lt;code&gt;() =&amp;gt; worker.terminate()&lt;/code&gt; fixed it, and it is also the correct production behavior when a user navigates away mid-parse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hoisted URL produced a 404 in production.&lt;/strong&gt; Development worked; the deployed build requested a worker path that did not exist and got an HTML error page back. The cause was refactoring &lt;code&gt;new URL(...)&lt;/code&gt; into a shared constant, which defeats the bundler's static detection. Keep the expression inline inside the constructor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors vanished.&lt;/strong&gt; An exception thrown inside a raw worker does not reject anything on the main thread; it fires an &lt;code&gt;error&lt;/code&gt; event on the worker object. I now always attach &lt;code&gt;worker.onerror&lt;/code&gt;, and with Comlink I wrap calls in try/catch — remembering that only the message and stack survive the boundary, so a custom error class arrives as a generic object unless you register a &lt;code&gt;Comlink.transferHandlers&lt;/code&gt; entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SharedArrayBuffer was not an option.&lt;/strong&gt; Sharing memory between threads without copying requires cross-origin isolation: &lt;code&gt;Cross-Origin-Opener-Policy: same-origin&lt;/code&gt; plus &lt;code&gt;Cross-Origin-Embedder-Policy: require-corp&lt;/code&gt;. Those headers broke embedded third-party widgets on the same page, so I stayed with transferables. If your app embeds anything cross-origin, budget real time before assuming &lt;code&gt;SharedArrayBuffer&lt;/code&gt; is available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One worker was enough.&lt;/strong&gt; I built a pool sized by &lt;code&gt;navigator.hardwareConcurrency&lt;/code&gt; before measuring, then found a single worker already removed every long task from the trace. A pool is worth it when you have genuinely parallel independent chunks; it is not a default.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Do Web Workers work with Server Components in the Next.js App Router?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not directly. &lt;code&gt;Worker&lt;/code&gt; is a browser API, so the file that constructs it needs the &lt;code&gt;'use client'&lt;/code&gt; directive, and construction must happen inside &lt;code&gt;useEffect&lt;/code&gt; or an event handler so it never runs during server rendering. A Server Component can freely render the Client Component that owns the worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does moving work to a Web Worker improve INP?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only when the interaction is blocked by CPU-bound JavaScript you control. Interaction to Next Paint measures the delay from an interaction to the next frame, so moving a 400 ms parse off the main thread helps directly, while a slow React commit is unaffected because rendering cannot leave the main thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does Turbopack support new Worker(new URL(...))?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Turbopack, the default bundler for &lt;code&gt;next dev&lt;/code&gt; and &lt;code&gt;next build&lt;/code&gt; in Next.js 16, detects the &lt;code&gt;new URL('./file', import.meta.url)&lt;/code&gt; pattern inside a &lt;code&gt;Worker&lt;/code&gt; constructor and emits the worker as its own chunk, the same way webpack 5 does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What is the difference between a Web Worker and a Service Worker?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A Web Worker is a background compute thread owned by one page and terminated with it. A Service Worker is a network proxy between the page and the network that persists across page loads and powers offline caching and push notifications; it is not a place to run heavy computation for the current page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I share state between the main thread and a worker without copying?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, with &lt;code&gt;SharedArrayBuffer&lt;/code&gt;, but the page must be cross-origin isolated via COOP and COEP response headers. Without those headers, use transferable objects, which move ownership of an &lt;code&gt;ArrayBuffer&lt;/code&gt; in constant time instead of sharing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The mental model that finally made workers easy: a worker is a tiny server that happens to run in the same tab. You send it a request, it answers with data, and everything crossing the gap must survive serialization. Once I stopped sending rich objects across the boundary and started sending buffers and plain JSON, the confusing failures went away.&lt;/p&gt;

&lt;p&gt;The discipline is measurement first. Open a trace, find the long task, read the flame chart, and only then reach for a thread. Web Workers fix one specific problem very well, and they add real complexity to everything else.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/web-workers-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/web-workers-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Turborepo in a Real Monorepo: Field Notes on Cache Misses, NEXT_PUBLIC Poisoning, and the tasks Key That Replaced pipeline</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Thu, 03 Sep 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/turborepo-in-a-real-monorepo-field-notes-on-cache-misses-nextpublic-poisoning-and-the-tasks-key-4ojn</link>
      <guid>https://dev.to/ahmed_mahmoud360/turborepo-in-a-real-monorepo-field-notes-on-cache-misses-nextpublic-poisoning-and-the-tasks-key-4ojn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Turborepo is a task runner that hashes each script's inputs, caches its outputs, and replays them instead of re-running the work. The cache is only as correct as the &lt;code&gt;inputs&lt;/code&gt;, &lt;code&gt;outputs&lt;/code&gt;, and &lt;code&gt;env&lt;/code&gt; you declare in &lt;code&gt;turbo.json&lt;/code&gt; — every wrong answer I got out of it was a declaration I never wrote.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I moved a two-app, six-package monorepo onto Turborepo 2 this year: a Next.js front end, a NestJS API, and shared UI, config, and type packages. The first week felt like a straight win, because CI stopped rebuilding packages nobody had touched. The second week I shipped a production build that was still pointing at the staging API, and the deploy log said &lt;code&gt;cache hit, replaying logs&lt;/code&gt;. These are the notes I wish I had before that deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo is a memoization layer over &lt;code&gt;package.json&lt;/code&gt; scripts.&lt;/strong&gt; It does not install dependencies, link workspaces, or compile anything — pnpm and &lt;code&gt;tsc&lt;/code&gt; still do that work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo 2.0 renamed the top-level &lt;code&gt;pipeline&lt;/code&gt; key in &lt;code&gt;turbo.json&lt;/code&gt; to &lt;code&gt;tasks&lt;/code&gt;.&lt;/strong&gt; Run &lt;code&gt;npx @turbo/codemod@latest migrate&lt;/code&gt; instead of editing the file by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An undeclared environment variable is the most dangerous cache input.&lt;/strong&gt; Next.js inlines &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; values into the client bundle at build time, so a cache hit can physically replay another environment's URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo 2.0 defaults to strict environment mode&lt;/strong&gt;, where a task only sees variables listed in &lt;code&gt;env&lt;/code&gt; or &lt;code&gt;globalEnv&lt;/code&gt;. That default converts a silent wrong-value bug into a loud undefined one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;--affected&lt;/code&gt; flag is only as good as your git history.&lt;/strong&gt; A shallow CI clone has no base commit to diff against, so the filter quietly degrades into building everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does Turborepo actually do, and what does it not replace?
&lt;/h2&gt;

&lt;p&gt;Turborepo is a task runner for JavaScript monorepos that builds a dependency graph of your scripts, hashes the inputs of each task, and restores cached outputs when the hash matches a previous run. That is the entire product. It does not install packages, does not resolve workspace links, and does not bundle a single byte.&lt;/p&gt;

&lt;p&gt;The division of labour is worth stating plainly, because it decides where to debug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pnpm, npm, or yarn workspaces&lt;/strong&gt; install dependencies and symlink internal packages. Turborepo reads that workspace definition; it does not create it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tsc&lt;/code&gt;, &lt;code&gt;tsup&lt;/code&gt;, the Next.js compiler, and the Nest CLI&lt;/strong&gt; do the actual compiling. Turborepo runs them as ordinary scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo&lt;/strong&gt; decides what runs, in what order, across how many cores — and whether it needs to run at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful mental model is memoization. &lt;code&gt;turbo run build&lt;/code&gt; is a memoized call to &lt;code&gt;pnpm build&lt;/code&gt; in every package, keyed on a hash of the source files, the dependency graph, the resolved lockfile entries, and the declared environment variables. Memoization is only correct when the function is deterministic with respect to its declared arguments. Every Turborepo bug I have hit was an argument I forgot to declare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does turbo.json use tasks instead of pipeline now?
&lt;/h2&gt;

&lt;p&gt;Turborepo 2.0 renamed the top-level &lt;code&gt;pipeline&lt;/code&gt; key to &lt;code&gt;tasks&lt;/code&gt;, because the old name implied a sequential pipeline when the value is really a set of task definitions whose order comes entirely from &lt;code&gt;dependsOn&lt;/code&gt;. A configuration file still using &lt;code&gt;pipeline&lt;/code&gt; fails on Turborepo 2 with a schema error, and &lt;code&gt;npx @turbo/codemod@latest migrate&lt;/code&gt; renames it along with the other 2.x migrations.&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;"$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;"https://turbo.build/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tui"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"globalEnv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"NODE_ENV"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&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;"build"&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;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"$TURBO_DEFAULT$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".env*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!.next/cache/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist/**"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"NEXT_PUBLIC_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DATABASE_URL"&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;"test"&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;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"coverage/**"&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;"dev"&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;"cache"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"persistent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Two pieces of that syntax carry most of the meaning. &lt;code&gt;"dependsOn": ["^build"]&lt;/code&gt; with the caret means "build every package this package depends on first", walking up the dependency graph. &lt;code&gt;"dependsOn": ["build"]&lt;/code&gt; without the caret means "run the build task of this same package first". Mixing those two up produces a graph that reads correctly and schedules wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;persistent: true&lt;/code&gt; marks a task that never exits, such as a dev server. Turborepo 2 refuses to let any other task declare a dependency on a persistent task, which is the right error to raise: a task waiting on something that never finishes is a deadlock, not a build order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is my build cache always missing?
&lt;/h2&gt;

&lt;p&gt;A Turborepo task misses the cache when something inside its hash changed, and it caches nothing at all when its &lt;code&gt;outputs&lt;/code&gt; array is empty. Those are two different failures with one symptom — a full rebuild every time — and &lt;code&gt;turbo run build --dry=json&lt;/code&gt; tells them apart without running anything, because it prints each task's resolved inputs, environment variables, and final hash.&lt;/p&gt;

&lt;p&gt;For a run that already happened, &lt;code&gt;turbo run build --summarize&lt;/code&gt; writes a JSON file into &lt;code&gt;.turbo/runs/&lt;/code&gt; containing the hash breakdown per task. Diffing two of those files across a miss is the fastest way I know to find the single input that moved.&lt;/p&gt;

&lt;p&gt;Two shapes are worth recognising on sight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A hash that never repeats&lt;/strong&gt; usually means a generated artifact is being counted as source. Turborepo's default inputs are the git-tracked files in the package, so a committed &lt;code&gt;dist/&lt;/code&gt; folder or a build-stamped file makes every run unique. Gitignore it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hash that never changes when it should&lt;/strong&gt; means a real input is invisible. &lt;code&gt;.env.local&lt;/code&gt; is the classic case: it is gitignored, so it is not in the default inputs, so editing it invalidates nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix for the second case is the &lt;code&gt;$TURBO_DEFAULT$&lt;/code&gt; sentinel, which means "the default inputs, plus what I list next". Writing &lt;code&gt;"inputs": [".env*"]&lt;/code&gt; on its own replaces the defaults entirely and silently stops tracking your source files, which is a far worse bug than the one you set out to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did a cached build ship the wrong NEXT_PUBLIC value?
&lt;/h2&gt;

&lt;p&gt;Next.js inlines every &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variable into the client bundle at build time as a string literal, so the compiled &lt;code&gt;.next&lt;/code&gt; output physically contains the values from whichever environment produced it. If those variables are not listed in the task's &lt;code&gt;env&lt;/code&gt; array, Turborepo's hash cannot see them, the build looks identical to a previous one, and the cache restores a bundle pointing at the wrong API. Nothing throws. The build is green.&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;"build"&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;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"NEXT_PUBLIC_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NEXTAUTH_URL"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"passThroughEnv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"AWS_SECRET_ACCESS_KEY"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!.next/cache/**"&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;Turborepo 2.0 changed the default environment mode to &lt;code&gt;strict&lt;/code&gt;, and that default is the real fix. In strict mode a task's process receives only the variables named in &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;globalEnv&lt;/code&gt;, plus a small built-in allowlist. An undeclared variable is therefore &lt;code&gt;undefined&lt;/code&gt; at build time rather than quietly present, which turns an invisible wrong-value bug into an obvious failure you catch locally.&lt;/p&gt;

&lt;p&gt;Two distinctions I now apply mechanically. &lt;code&gt;env&lt;/code&gt; is for variables that change the artifact, so they belong in the hash. &lt;code&gt;passThroughEnv&lt;/code&gt; is for variables a task needs at runtime but that provably cannot change its output, such as an upload credential used after the bundle is written; those reach the process without joining the hash. &lt;code&gt;globalEnv&lt;/code&gt; is for variables that affect every task in the repo, which in practice is a very short list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should internal packages export TypeScript source or compiled output?
&lt;/h2&gt;

&lt;p&gt;Export raw TypeScript source from an internal package when every consumer is a bundler, and compile to JavaScript when any consumer is a Node process or an npm publish target. Turborepo's documentation calls these Just-in-Time Packages and Compiled Packages, and the choice changes how much there is to cache in the first place.&lt;/p&gt;

&lt;p&gt;A Just-in-Time package has no build step at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@repo/ui"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&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;"."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./src/index.ts"&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 consuming Next.js app then needs &lt;code&gt;transpilePackages: ['@repo/ui']&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt; so its compiler handles the untranspiled source. The upside is that there is no build task, so there is no cache entry to invalidate and no stale &lt;code&gt;dist/&lt;/code&gt; to debug. The downside is that every consuming app compiles the shared code again, and a NestJS service started with &lt;code&gt;node dist/main.js&lt;/code&gt; cannot import a &lt;code&gt;.ts&lt;/code&gt; file at all.&lt;/p&gt;

&lt;p&gt;A Compiled package pays a build step to remove those limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@repo/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&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;"."&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;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&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;"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;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsup src/index.ts --format esm --dts"&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;My rule after living with both: UI components and type-only packages stay Just-in-Time, and anything imported by a Node runtime or published outside the repo gets compiled. Splitting on that line kept the build graph shallow, because most packages have no build task and &lt;code&gt;^build&lt;/code&gt; has almost nothing to wait for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I build only what changed in CI?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;turbo run build --affected&lt;/code&gt; restricts a run to the packages touched since the base branch, and it is the flag Turborepo 2.1 added to replace the older &lt;code&gt;--filter=...[origin/main]&lt;/code&gt; syntax. It compares against &lt;code&gt;main&lt;/code&gt; by default, and the &lt;code&gt;TURBO_SCM_BASE&lt;/code&gt; and &lt;code&gt;TURBO_SCM_HEAD&lt;/code&gt; variables override both ends of the comparison, which is what pull-request builds need.&lt;/p&gt;

&lt;p&gt;The gotcha that cost me an afternoon is that &lt;code&gt;--affected&lt;/code&gt; is a git operation. GitHub Actions' &lt;code&gt;actions/checkout&lt;/code&gt; defaults to &lt;code&gt;fetch-depth: 1&lt;/code&gt;, so the runner holds exactly one commit and has no base to diff against — and the run degrades into building everything with no error explaining why. Setting &lt;code&gt;fetch-depth: 0&lt;/code&gt; in the checkout step makes the flag behave.&lt;/p&gt;

&lt;p&gt;Remote caching is what makes any of this matter on ephemeral runners. Without it, every CI job starts with an empty cache and the local hits you enjoy on your laptop help nobody else. &lt;code&gt;turbo login&lt;/code&gt; followed by &lt;code&gt;turbo link&lt;/code&gt; wires up Vercel's remote cache, and setting &lt;code&gt;TURBO_API&lt;/code&gt;, &lt;code&gt;TURBO_TOKEN&lt;/code&gt;, and &lt;code&gt;TURBO_TEAM&lt;/code&gt; points the same client at a self-hosted one.&lt;/p&gt;

&lt;p&gt;For Docker, &lt;code&gt;turbo prune &amp;lt;app&amp;gt; --docker&lt;/code&gt; writes an &lt;code&gt;out/json&lt;/code&gt; directory containing only the lockfile and the &lt;code&gt;package.json&lt;/code&gt; files of that app's subgraph, plus an &lt;code&gt;out/full&lt;/code&gt; directory with the source. Copying &lt;code&gt;out/json&lt;/code&gt; and installing before copying &lt;code&gt;out/full&lt;/code&gt; means the install layer only invalidates when a dependency actually changes, rather than on every source edit.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Does Turborepo replace pnpm workspaces?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Turborepo runs and caches tasks, while the package manager still installs dependencies and links internal packages. Turborepo reads the workspace definition from &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; or the &lt;code&gt;workspaces&lt;/code&gt; field in the root &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How is Turborepo different from Nx?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Both build a task graph and cache task outputs locally and remotely. Turborepo is configuration-only, wrapping the &lt;code&gt;package.json&lt;/code&gt; scripts you already have in one &lt;code&gt;turbo.json&lt;/code&gt;, while Nx adds plugins, generators, executors, and inferred targets. If your scripts already work, Turborepo is the smaller migration; if you want scaffolding and per-framework integrations, Nx does more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can a Turborepo cache hit be wrong?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. A cache hit is exactly as honest as the declared &lt;code&gt;inputs&lt;/code&gt; and &lt;code&gt;env&lt;/code&gt;. If a file or variable changes the output but is not part of the hash, Turborepo will confidently replay a stale artifact, so verify a suspicious hit with &lt;code&gt;turbo run build --dry=json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why does my dev task need persistent: true?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A dev server never exits, and &lt;code&gt;persistent: true&lt;/code&gt; tells Turborepo not to wait for it. Turborepo 2 also rejects any task that declares &lt;code&gt;dependsOn&lt;/code&gt; on a persistent task, because that dependency could never be satisfied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is remote caching worth it for a solo developer?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; For local work, no — the local cache under &lt;code&gt;node_modules/.cache/turbo&lt;/code&gt; already covers it. For CI, yes, because CI containers are ephemeral and start with an empty cache on every run, which is exactly where a full rebuild costs the most.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/turborepo-monorepo-caching-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/turborepo-monorepo-caching-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>turborepo</category>
      <category>monorepo</category>
      <category>nextjs</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
