<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Dan Holloran - development</title>
        <link>https://danholloran.me/posts/topic/development</link>
        <description>Posts on development from Dan Holloran.</description>
        <lastBuildDate>Thu, 12 Nov 2026 14:53:22 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>© 2026 Dan Holloran</copyright>
        <atom:link href="https://danholloran.me/topic/development/feed.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Laravel 13 Vector Search: Semantic Queries Without a Search Service]]></title>
            <link>https://danholloran.me/posts/laravel-13-vector-search-semantic-queries-without-a-search-service</link>
            <guid isPermaLink="false">https://danholloran.me/posts/laravel-13-vector-search-semantic-queries-without-a-search-service</guid>
            <pubDate>Mon, 09 Nov 2026 10:15:12 GMT</pubDate>
            <description><![CDATA[Laravel 13 adds whereVectorSimilarTo, vector columns, and an AsVector cast, so meaning-based search can live in the Postgres or MariaDB database you run.]]></description>
            <content:encoded><![CDATA[<p>A user types &quot;cheap places to stay near the beach&quot; into your help center search, and your <code>LIKE '%beach%'</code> query returns an article about beach volleyball rules. Meanwhile, the article titled &quot;Budget Coastal Rentals&quot; sits unfound because none of its words match. Full-text search helps with stemming, but it still matches words, not meaning.</p>
<p>The usual fix has been to bolt on a dedicated vector database or a hosted search service, then write a sync pipeline to keep it in step with your models. Laravel 13 removes most of that work. Vector columns, an Eloquent cast, and a <code>whereVectorSimilarTo</code> query builder method now ship in the framework, so semantic search can live in the database you already run.</p>
<h2 id="what-you-need-before-you-start" tabindex="-1">What you need before you start <a class="header-anchor" href="#what-you-need-before-you-start" aria-label="Permalink to &quot;What you need before you start&quot;">&ZeroWidthSpace;</a></h2>
<p>Vector search in Laravel 13 depends on two things. First, a database that can store and compare vectors: PostgreSQL with the <code>pgvector</code> extension or MariaDB 11.7 and later (MongoDB works through the Laravel MongoDB package). Second, the first-party <a href="https://laravel.com/docs/13.x/ai-sdk" target="_blank" rel="noreferrer">Laravel AI SDK</a>, which handles calling an embedding provider and turning text into arrays of numbers.</p>
<p>An embedding is a long array of floats, often 1,536 of them, that encodes what a piece of text means. Texts with similar meanings land close together in that space, so &quot;budget coastal rentals&quot; and &quot;cheap places near the beach&quot; end up neighbors even with zero shared words.</p>
<p>The migration side is short:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Illuminate</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Database</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Schema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Blueprint</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Illuminate</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Support</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Facades</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Schema</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Schema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">ensureVectorExtensionExists</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Schema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">create</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">articles</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Blueprint</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">foreignId</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">team_id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">constrained</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">title</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">body</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">vector</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> dimensions</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1536</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">nullable</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">index</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">timestamps</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p><code>ensureVectorExtensionExists()</code> enables <code>pgvector</code> if it is not already on, and calling <code>index()</code> on a vector column creates an HNSW index using cosine distance. The <code>dimensions</code> value has to match your embedding model exactly, so 1536 for OpenAI's <code>text-embedding-3-small</code>. Swap models later and you are re-embedding every row, so pick deliberately.</p>
<p>On the model, the <code>AsVector</code> cast converts between PHP arrays and the database vector format:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Illuminate</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Database</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Eloquent</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Casts</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">AsVector</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">protected</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> casts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> array</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> AsVector</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::class,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    ];</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><h2 id="generating-and-storing-embeddings" tabindex="-1">Generating and storing embeddings <a class="header-anchor" href="#generating-and-storing-embeddings" aria-label="Permalink to &quot;Generating and storing embeddings&quot;">&ZeroWidthSpace;</a></h2>
<p>Embedding generation is a network call to a paid API, so it does not belong in a request cycle or a model <code>saving</code> hook. Push it to a queued job, and batch where you can. The AI SDK's <code>Embeddings</code> class sends many inputs in one API call:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> App</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Models</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Article</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Laravel</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Ai</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Embeddings</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Article</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">whereNull</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">chunkById</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">100</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ($</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">articles</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">response </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Embeddings</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">            $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">articles</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">fn</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ($</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">title</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> .</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">\n\n</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> .</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">body</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">all</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        )</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">generate</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        foreach</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ($</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">articles</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">values</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> as</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">i </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">article</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">            $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">article</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">update</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">([</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">response</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">embeddings</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">[$</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">i</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]]);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    });</span></span></code></pre>
</div><p>Embedding the title together with the body gives the vector more signal for short articles. For long documents, you will get better matches by splitting the content into chunks with their own rows and embeddings, because one vector has to summarize everything it covers.</p>
<h2 id="querying-by-meaning" tabindex="-1">Querying by meaning <a class="header-anchor" href="#querying-by-meaning" aria-label="Permalink to &quot;Querying by meaning&quot;">&ZeroWidthSpace;</a></h2>
<p>With embeddings stored, the search itself is one method:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">$</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">results </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Article</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">query</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">where</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">team_id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">user</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">team_id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">whereVectorSimilarTo</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">request</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">input</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">q</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> minSimilarity</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">4</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">limit</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">10</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span></code></pre>
</div><p><code>whereVectorSimilarTo</code> compares stored vectors using cosine similarity, drops anything below <code>minSimilarity</code> (a value from 0.0 to 1.0), and orders results with the closest match first. When you pass a plain string, Laravel embeds it for you with your configured provider, so the user's query goes straight in.</p>
<p>That convenience has a cost: every search is now an embedding API call, with the latency and per-token billing that comes with it. Turn on embedding caching in <code>config/ai.php</code> (<code>caching.embeddings.cache</code>), which keys results on provider, model, dimensions, and input and keeps them for 30 days. Popular queries then skip the network entirely.</p>
<p>If you need the raw numbers, the lower-level methods expose them:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">$</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">results </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Article</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">query</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">select</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">selectVectorDistance</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">queryEmbedding</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> as</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">distance</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">whereVectorDistanceLessThan</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">queryEmbedding</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> maxDistance</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">3</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">orderByVectorDistance</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">embedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">queryEmbedding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">limit</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">10</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    -></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span></code></pre>
</div><p>Selecting the distance is handy while you tune thresholds. Log a few real queries with their distances and you will quickly see where relevant results stop and noise starts. A threshold copied from a blog post (including this one) is a starting guess, not a setting.</p>
<h2 id="when-vector-search-is-the-wrong-tool" tabindex="-1">When vector search is the wrong tool <a class="header-anchor" href="#when-vector-search-is-the-wrong-tool" aria-label="Permalink to &quot;When vector search is the wrong tool&quot;">&ZeroWidthSpace;</a></h2>
<p>Semantic search is great at intent and bad at exactness. A user searching for an order number, a SKU, or an error code wants an exact match, and embeddings will happily return &quot;similar-looking&quot; codes. Keep <code>whereFullText</code> or a plain <code>where</code> for those cases, and consider routing queries that look like identifiers away from the vector path.</p>
<p>Also watch the combination of filters and approximate indexes. An HNSW index finds nearest neighbors first and your <code>where</code> clauses filter after, so a narrow filter like a single small team can return fewer than ten results. Newer <code>pgvector</code> releases add iterative index scans to help, but test with realistic data.</p>
<p>Laravel's <a href="https://laravel.com/docs/13.x/search" target="_blank" rel="noreferrer">search documentation</a> also covers a hybrid pattern worth knowing: retrieve 50 candidates with fast full-text search, then reorder them with the AI SDK's <code>rerank</code> collection macro. For many apps that beats pure vector search on both cost and relevance. Start with the version that needs the least infrastructure, measure it against real queries, and only add moving parts when the logs say you need them.</p>
]]></content:encoded>
            <category>laravel</category>
            <category>performance</category>
            <category>tooling</category>
            <enclosure url="https://danholloran.me/images/posts/laravel-13-vector-search-semantic-queries-without-a-search-service.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[React's browser() API: Opt Out of SSR Without the Mounted-State Hack]]></title>
            <link>https://danholloran.me/posts/reacts-browser-api-opt-out-of-ssr-without-the-mounted-state-hack</link>
            <guid isPermaLink="false">https://danholloran.me/posts/reacts-browser-api-opt-out-of-ssr-without-the-mounted-state-hack</guid>
            <pubDate>Sun, 08 Nov 2026 10:14:40 GMT</pubDate>
            <description><![CDATA[React 19.3 adds use(browser()), a first-class way to mark a component browser-only during SSR, replacing typeof window checks and useEffect mounted flags.]]></description>
            <content:encoded><![CDATA[<p>Every server-rendered React app eventually grows a component that just can't render on the server. It reads a draft from <code>localStorage</code>, shows the visitor's time zone, or wraps a charting library that touches <code>window</code> on import. So you reach for the usual workaround: a <code>mounted</code> flag set in an effect, or a <code>typeof window !== 'undefined'</code> check sprinkled through render.</p>
<p>Both work, sort of. The effect version renders nothing (or a placeholder) on the first client pass and then flips, which costs you an extra render and some layout shift. The <code>typeof window</code> version is worse, because it renders different output on the server and the client, which is exactly how you get a hydration mismatch. React 19.3, released in September 2026, finally ships a real answer: <code>use(browser())</code>.</p>
<h2 id="what-browser-actually-does" tabindex="-1">What <code>browser()</code> actually does <a class="header-anchor" href="#what-browser-actually-does" aria-label="Permalink to &quot;What `browser()` actually does&quot;">&ZeroWidthSpace;</a></h2>
<p><code>browser</code> is a new export from <code>react-dom</code>. You call it inside <code>use</code>, and it marks the component as browser-only during server rendering:</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Suspense</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> use</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useState </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> browser </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react-dom</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> SavedDraft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  use</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">browser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">The draft is stored in localStorage.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">draft</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> setDraft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useState</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> localStorage</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getItem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">draft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ??</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ""</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">textarea</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      value=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">draft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      onChange=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">e</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">        setDraft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">e</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">target</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">        localStorage</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">setItem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">draft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> e</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">target</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  );</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Editor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Suspense</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> fallback=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Loading draft...</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>}></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">SavedDraft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Suspense</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  );</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>On the server, <code>use(browser())</code> stops rendering that component and leaves the nearest <code>&lt;Suspense&gt;</code> fallback in the HTML. In the browser, it returns <code>undefined</code> and the component renders normally. Notice that the <code>useState</code> initializer can read <code>localStorage</code> directly. There's no guard and no second render, because this code only ever runs where <code>localStorage</code> exists.</p>
<p>The mental model is the nice part. Browser-only content is now just another thing that suspends. It shares loading states with your data fetching, it composes with streaming, and it shows up in the HTML as a proper fallback instead of an empty <code>&lt;div&gt;</code> that pops in later.</p>
<p>A few rules from the <a href="https://react.dev/reference/react-dom/browser" target="_blank" rel="noreferrer">official docs</a> worth memorizing:</p>
<ul>
<li>It must sit inside a <code>&lt;Suspense&gt;</code> boundary during server rendering. Without one, the server render fails.</li>
<li>It only works in Client Components. In a Server Components setup like Next.js, put it in a <code>'use client'</code> file.</li>
<li>Calling <code>browser()</code> by itself does nothing. Pass it to <code>use</code>, and don't throw it.</li>
</ul>
<h2 id="opting-out-conditionally" tabindex="-1">Opting out conditionally <a class="header-anchor" href="#opting-out-conditionally" aria-label="Permalink to &quot;Opting out conditionally&quot;">&ZeroWidthSpace;</a></h2>
<p>Because <code>use</code> can be called after an early return or inside an <code>if</code>, you can opt out of server rendering only when you actually need to. That's something the mounted-flag pattern never handled cleanly.</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> use </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> browser </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react-dom</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useTimeZone</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">defaultTimeZone</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">defaultTimeZone </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!==</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> undefined</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> defaultTimeZone</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  use</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">browser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">No default time zone was provided.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Intl</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">DateTimeFormat</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">resolvedOptions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">timeZone</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>If the event page passes <code>'America/New_York'</code>, the time zone lands in the server HTML. If it doesn't, the component suspends on the server and reads the device's zone in the browser.</p>
<p>The same trick works well for client-side data libraries. Wrap your query hook so it only skips SSR when there's no server-provided data:</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useBrowserQuery</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> options</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">options</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">initialData</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ===</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> undefined</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    use</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">browser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">useBrowserQuery: no initial data was provided.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useQuery</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> options</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Now <code>ProductDetails</code> renders to HTML when a loader or Server Component hands it <code>initialData</code>, and quietly defers to the browser when it doesn't.</p>
<h2 id="seeing-what-bailed-out-and-bailing-out-on-purpose" tabindex="-1">Seeing what bailed out, and bailing out on purpose <a class="header-anchor" href="#seeing-what-bailed-out-and-bailing-out-on-purpose" aria-label="Permalink to &quot;Seeing what bailed out, and bailing out on purpose&quot;">&ZeroWidthSpace;</a></h2>
<p>A browser bailout isn't an error, so React does not send it to <code>onError</code> or to <code>hydrateRoot</code>'s <code>onRecoverableError</code>. Instead, the server renderers accept a new <code>onBrowserBailout</code> callback. The string you passed to <code>browser()</code> becomes the reported error's <code>cause</code>, and you also get a component stack:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> pipe</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> renderToPipeableStream</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">App</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  onShellReady</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    pipe</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  onBrowserBailout</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> errorInfo</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    logger</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">info</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">browser-only render</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      reason</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">cause</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      stack</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> errorInfo</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">componentStack</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>That's worth wiring up. A browser-only component is a real SEO and first-paint tradeoff, and it's easy for a team to keep adding them until half the page is fallbacks. Logging them gives you a count to watch.</p>
<p>The same value also works as an abort reason. If a slow Suspense boundary is holding up your stream, you can hand it off to the client instead of failing:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">setTimeout</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  abort</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">browser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">The server render timed out.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 10000</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span></code></pre>
</div><p>Pending boundaries stay in their fallback state and finish rendering in the browser. That's a much friendlier timeout than a 500. If your rendering API takes an <code>AbortSignal</code>, pass <code>browser()</code> to <code>AbortController.abort</code> instead.</p>
<h2 id="when-not-to-reach-for-it" tabindex="-1">When not to reach for it <a class="header-anchor" href="#when-not-to-reach-for-it" aria-label="Permalink to &quot;When not to reach for it&quot;">&ZeroWidthSpace;</a></h2>
<p><code>browser()</code> makes skipping the server easy, and that's the risk. Anything inside the boundary is missing from the initial HTML, so crawlers and slow connections see your fallback. Before reaching for it, ask whether the component could render a sensible default on the server, like a saved preference from a cookie instead of <code>localStorage</code>, or a time zone from the request. The conditional pattern above is usually the right middle ground.</p>
<p>When a component truly can't render on the server, though, this is the cleanest tool React has offered. Upgrade to React 19.3, delete your <code>useIsMounted</code> hook, and read the <a href="https://react.dev/blog/2026/09/09/react-19-3" target="_blank" rel="noreferrer">React 19.3 release post</a> for the other headline features, <code>&lt;ViewTransition&gt;</code> and Fragment Refs.</p>
]]></content:encoded>
            <category>react</category>
            <category>javascript</category>
            <category>performance</category>
            <category>react.js</category>
            <enclosure url="https://danholloran.me/images/posts/reacts-browser-api-opt-out-of-ssr-without-the-mounted-state-hack.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Oxlint JS Plugins: Your Custom ESLint Rules No Longer Keep You on ESLint]]></title>
            <link>https://danholloran.me/posts/oxlint-js-plugins-your-custom-eslint-rules-no-longer-keep-you-on-eslint</link>
            <guid isPermaLink="false">https://danholloran.me/posts/oxlint-js-plugins-your-custom-eslint-rules-no-longer-keep-you-on-eslint</guid>
            <pubDate>Thu, 05 Nov 2026 16:06:24 GMT</pubDate>
            <description><![CDATA[Oxlint now runs ESLint plugins and your own custom rules, so the last excuse for a slow lint step is gone. Here's how JS plugins work and where the gaps are.]]></description>
            <content:encoded><![CDATA[<p>Every team that has looked at Oxlint has hit the same wall. The benchmarks are great, the built-in rules cover most of what you use, and then someone remembers the twelve custom rules in <code>eslint-local-rules/</code>, plus <code>eslint-plugin-testing-library</code>, plus that one SonarJS rule the security folks insisted on. None of that exists in Rust, so the migration dies in the planning doc and CI keeps spending two minutes on linting.</p>
<p>That wall is mostly gone now. Oxlint's JavaScript plugin support hit alpha in March 2026, and the Oxc team's claim is that roughly 80% of ESLint users can switch and have it &quot;just work.&quot; The interesting part is how they got there: an ESLint-compatible plugin API running beside the native Rust rules, so you keep your rules and only pay JavaScript prices for the rules that actually need JavaScript.</p>
<h2 id="existing-plugins-point-at-them-and-go" tabindex="-1">Existing plugins: point at them and go <a class="header-anchor" href="#existing-plugins-point-at-them-and-go" aria-label="Permalink to &quot;Existing plugins: point at them and go&quot;">&ZeroWidthSpace;</a></h2>
<p>JS plugins are loaded through a <code>jsPlugins</code> array in your config. Any valid import specifier works, whether that's an npm package or a local file, and paths resolve relative to the config file.</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">jsPlugins</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">eslint-plugin-testing-library</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./lint/local-rules.js</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">rules</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">testing-library/no-render-in-lifecycle</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">local-rules/no-legacy-api-client</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>The compatibility story is backed by numbers rather than vibes. Oxc runs its JS plugin layer against ESLint's own rule test suite (33,006 tests, 100% passing) and against popular plugins like <code>eslint-plugin-react-hooks</code>, ESLint Stylistic, Testing Library, and SonarJS, all at or near 100%. Plugins that aren't on that list will often work anyway, as long as they target the ESLint v9 API. Plugins still using APIs ESLint removed in v9 or earlier won't be patched up for you.</p>
<p>One wrinkle you'll hit quickly is name collisions. Oxlint natively implements some plugins (like <code>jsdoc</code> and <code>react</code>) but not every rule in them. Aliasing lets you use the native rules where they exist and fall back to the npm package for the rest:</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">jsPlugins</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [{</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">name</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">jsdoc-js</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">specifier</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">eslint-plugin-jsdoc</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">rules</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">jsdoc/require-property-description</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">jsdoc-js/check-examples</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>The same trick works for ESLint core rules that Oxlint hasn't ported yet. The <code>oxlint-plugin-eslint</code> package ships all of ESLint's built-ins as a JS plugin under the <code>eslint-js</code> prefix, which is how you get <code>no-restricted-syntax</code> and its AST selectors back.</p>
<h2 id="writing-rules-same-api-one-optional-tweak" tabindex="-1">Writing rules: same API, one optional tweak <a class="header-anchor" href="#writing-rules-same-api-one-optional-tweak" aria-label="Permalink to &quot;Writing rules: same API, one optional tweak&quot;">&ZeroWidthSpace;</a></h2>
<p>If you've written an ESLint rule before, you already know how to write an Oxlint rule. The plain <code>create(context)</code> shape works unchanged. Oxlint also offers an alternative <code>createOnce</code> API that's worth adopting for anything new:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// lint/local-rules.js</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> eslintCompatPlugin </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@oxlint/plugins</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> noLegacyApiClient</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  createOnce</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">      ImportDeclaration</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">node</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">node</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">source</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@/lib/legacy-api</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">          context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">report</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">            node</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">            message</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Use the generated client in @/lib/api instead.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">          });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">};</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> eslintCompatPlugin</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  meta</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> name</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">local-rules</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  rules</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">no-legacy-api-client</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> noLegacyApiClient </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>The difference is that ESLint's <code>create</code> runs once per file, so the linter has to call into JavaScript just to find out which AST nodes your rule cares about. <code>createOnce</code> runs a single time, which lets Oxlint learn your visitor up front and, eventually, skip files that contain none of those nodes without crossing the Rust/JS boundary at all. Any per-file state moves into a <code>before()</code> hook. The docs are candid that this doesn't make rules faster yet; it positions them to get faster for free in later releases. And because <code>eslintCompatPlugin</code> adds a <code>create</code> shim, the same plugin still runs in plain ESLint, which matters during a gradual migration.</p>
<p>For tests, <code>oxlint/plugins-dev</code> exports a <code>RuleTester</code> modeled on ESLint's, and it slots into Vitest by assigning <code>RuleTester.describe</code> and <code>RuleTester.it</code>.</p>
<h2 id="migrating-and-what-still-doesn-t-fit" tabindex="-1">Migrating, and what still doesn't fit <a class="header-anchor" href="#migrating-and-what-still-doesn-t-fit" aria-label="Permalink to &quot;Migrating, and what still doesn't fit&quot;">&ZeroWidthSpace;</a></h2>
<p>The migration path is a single command against your flat config:</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">npx</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> @oxlint/migrate</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> eslint.config.js</span></span></code></pre>
</div><p>On the Node.js repo (6,298 files, 98 JS rules including 23 custom ones) the Oxc team measured ESLint at 1 minute 43 seconds and Oxlint at 21 seconds. That 4.8x is the low end, because so much of that project's rule set still runs in JavaScript. Projects leaning on typescript-eslint see bigger wins, since type-aware linting went stable in July 2026 with 59 of typescript-eslint's 61 type-aware rules running on the Go-based TypeScript 7 compiler. Turn it on with <code>&quot;options&quot;: { &quot;typeAware&quot;: true }</code> and use <code>oxlint --type-aware --debug timings</code> to find out which rules are eating your lint budget.</p>
<p>Before you rip ESLint out, check three things:</p>
<ul>
<li><strong>Vue, Svelte, and Angular templates.</strong> Custom file formats and parsers aren't supported for JS plugins yet. If your rules need to see <code>&lt;template&gt;</code> blocks, keep ESLint for those files for now.</li>
<li><strong>Custom type-aware rules.</strong> typescript-eslint's rules are built in, but your own rules can't ask the type checker anything yet.</li>
<li><strong>Slow plugins stay slow.</strong> Oxlint gives your rules a fast parser, but a plugin that hits the filesystem on every node is just as expensive here. If you lint formatting with ESLint Stylistic, moving that work to a formatter is the bigger win.</li>
</ul>
<p>Oxlint also runs fine alongside ESLint, so the low-risk move is to let Oxlint handle everything it can and shrink your ESLint config down to the leftovers. Start with <code>@oxlint/migrate</code>, read what it couldn't convert, and decide from there. The <a href="https://oxc.rs/docs/guide/usage/linter/js-plugins.html" target="_blank" rel="noreferrer">JS plugins guide</a> and <a href="https://oxc.rs/docs/guide/usage/linter/writing-js-plugins.html" target="_blank" rel="noreferrer">writing plugins docs</a> cover the rest.</p>
]]></content:encoded>
            <category>tooling</category>
            <category>javascript</category>
            <category>typescript</category>
            <category>testing</category>
            <enclosure url="https://danholloran.me/images/posts/oxlint-js-plugins-your-custom-eslint-rules-no-longer-keep-you-on-eslint.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[GraphQL @defer: Stop Letting Your Slowest Field Set Your Load Time]]></title>
            <link>https://danholloran.me/posts/graphql-defer-stop-letting-your-slowest-field-set-your-load-time</link>
            <guid isPermaLink="false">https://danholloran.me/posts/graphql-defer-stop-letting-your-slowest-field-set-your-load-time</guid>
            <pubDate>Mon, 02 Nov 2026 10:15:15 GMT</pubDate>
            <description><![CDATA[One slow resolver can hold a whole GraphQL response hostage. Here's how @defer and @stream split a query into an initial payload and patches, and what to check.]]></description>
            <content:encoded><![CDATA[<p>The pitch for GraphQL is that a page asks for everything it needs in one request. The catch nobody puts on the slide: that one request is only as fast as its slowest field. Your product page has <code>name</code>, <code>price</code>, and <code>image</code> ready in 20ms, but <code>reviews</code> calls a service that takes 900ms, so the whole response waits 900ms. The user stares at a spinner for data you already had.</p>
<p>The usual workaround is to split the query in two and fire the slow one separately. That works, but it throws away the thing you liked about GraphQL in the first place: one operation that describes one screen. <code>@defer</code> and <code>@stream</code> are the spec-track fix. They let a single query return an initial payload right away and send the slow parts later as patches.</p>
<h2 id="how-defer-works" tabindex="-1">How @defer works <a class="header-anchor" href="#how-defer-works" aria-label="Permalink to &quot;How @defer works&quot;">&ZeroWidthSpace;</a></h2>
<p><code>@defer</code> goes on a fragment spread or an inline fragment. Everything outside the deferred fragment comes back in the first payload. The fragment's fields arrive in a later payload once they resolve.</p>
<div class="language-graphql vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">graphql</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">query</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> ProductPage</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">$id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ID</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  product</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> $id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    id</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    name</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    price</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    ...</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Reviews </span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">@defer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">label</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">reviews</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">fragment</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Reviews </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">on</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Product </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  reviews</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">first</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 10</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    body</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    rating</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>The client gets <code>id</code>, <code>name</code>, and <code>price</code> immediately and can render the page. A second payload carries <code>reviews</code>, and the client merges it into the same result. You can also gate it with a variable, <code>@defer(if: $deferReviews)</code>, which is handy when the same fragment serves a server render that wants everything at once.</p>
<p><code>@stream</code> is the list version. It sends the first <code>initialCount</code> items in the initial payload and streams the rest:</p>
<div class="language-graphql vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">graphql</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">query</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Feed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  feed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">first</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 100</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> @stream</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">initialCount</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 10</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    id</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    title</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>That pairs well with a resolver that returns an async iterable, since the executor can push each item as soon as it exists.</p>
<h2 id="wiring-it-up-on-the-server" tabindex="-1">Wiring it up on the server <a class="header-anchor" href="#wiring-it-up-on-the-server" aria-label="Permalink to &quot;Wiring it up on the server&quot;">&ZeroWidthSpace;</a></h2>
<p>GraphQL.js 17 shipped stable in June 2026 with <code>@defer</code> and <code>@stream</code> included but deliberately opt-in. The directives are not part of <code>specifiedDirectives</code>, so you add them yourself, and you run operations through a separate executor:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  GraphQLSchema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  GraphQLDeferDirective</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  GraphQLStreamDirective</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  specifiedDirectives</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  experimentalExecuteIncrementally</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  parse</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">graphql</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> schema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> GraphQLSchema</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  query</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> QueryType</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">  // Passing `directives` replaces the defaults, so keep specifiedDirectives</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  directives</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    ...</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">specifiedDirectives</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    GraphQLDeferDirective</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    GraphQLStreamDirective</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  ]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> result</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> experimentalExecuteIncrementally</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  schema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  document</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> parse</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">source</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  variableValues</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">initialResult</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> in</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> result</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  send</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">result</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">initialResult</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  for</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> patch</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> of</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> result</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">subsequentResults</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    send</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">patch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> else</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  send</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">result</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Two details bite people here. First, forgetting <code>...specifiedDirectives</code> silently drops <code>@skip</code>, <code>@include</code>, and friends. Second, the plain <code>execute()</code> function rejects schemas that contain these directives, so every code path that executes operations needs to switch.</p>
<p>GraphQL.js produces the payloads but not the wire format. Your server picks the framing, usually HTTP multipart (<code>multipart/mixed</code>), sometimes server-sent events. Apollo Router supports <code>@defer</code> out of the box, so if your graph sits behind it you may already have this plumbing.</p>
<h2 id="the-client-has-to-speak-the-same-dialect" tabindex="-1">The client has to speak the same dialect <a class="header-anchor" href="#the-client-has-to-speak-the-same-dialect" aria-label="Permalink to &quot;The client has to speak the same dialect&quot;">&ZeroWidthSpace;</a></h2>
<p>This is the part to slow down on. Incremental delivery is still a draft in the GraphQL spec (the RFC reached Stage 2 in March 2026 and is still being edited), and the response format has changed more than once along the way. Apollo Client 4 refuses to guess. Out of the box it throws if it sees <code>@defer</code>, and you have to opt into a specific format:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ApolloClient</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> InMemoryCache</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> HttpLink </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@apollo/client</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Defer20220824Handler </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@apollo/client/incremental</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> client</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> ApolloClient</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  cache</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> InMemoryCache</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  link</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> HttpLink</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> uri</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/graphql</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> })</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  incrementalHandler</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Defer20220824Handler</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p><code>Defer20220824Handler</code> matches the format Apollo Router ships. There is also a <code>GraphQL17Alpha9Handler</code> for servers built on the newer GraphQL.js format. Pick the one that matches what your server actually sends, then test a real deferred query end to end. A mismatch shows up as data that never merges, not as a helpful error.</p>
<p>In the UI, plan for fields that aren't there yet. In Apollo Client 4, <code>dataState</code> moves from <code>&quot;empty&quot;</code> to <code>&quot;streaming&quot;</code> to <code>&quot;complete&quot;</code>, and <code>loading</code> stays <code>true</code> until the last patch lands:</p>
<div class="language-tsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">tsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> data</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dataState</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useQuery</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">PRODUCT_PAGE</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> variables</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">dataState </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">empty</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Skeleton</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> />;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">ProductHeader</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> product=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">product</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">product</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">reviews</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ?</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Reviews</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> items=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">product</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">reviews</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    )</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> :</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">ReviewsSkeleton</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    )}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;/></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span></code></pre>
</div><p>Reserve space for the deferred section so the late data doesn't shove the page around. Otherwise you trade a slow load for a layout shift.</p>
<h2 id="when-not-to-bother" tabindex="-1">When not to bother <a class="header-anchor" href="#when-not-to-bother" aria-label="Permalink to &quot;When not to bother&quot;">&ZeroWidthSpace;</a></h2>
<p><code>@defer</code> fixes one problem: a fast majority waiting on a slow minority. If everything in the query is slow, deferring just adds payloads. If the slow field is slow because of an N+1 query, fix the resolver with batching first. Deferring a bad resolver hides the cost from the user while your database keeps paying it. Some gateways also don't support it yet, so check every hop between the client and the resolver before you count on it.</p>
<p>If your screens do have that &quot;nine fast fields and one slow one&quot; shape, <code>@defer</code> is worth an afternoon. Start with one below-the-fold section, measure time to first render before and after, and read the <a href="https://www.graphql-js.org/docs/defer-stream/" target="_blank" rel="noreferrer">GraphQL.js defer and stream guide</a> and the <a href="https://www.apollographql.com/docs/react/data/defer" target="_blank" rel="noreferrer">Apollo Client @defer docs</a> before you choose a format.</p>
]]></content:encoded>
            <category>graphql</category>
            <category>performance</category>
            <category>javascript</category>
            <category>react</category>
            <enclosure url="https://danholloran.me/images/posts/graphql-defer-stop-letting-your-slowest-field-set-your-load-time.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[bfcache: The Instant Back Button Your Site Is Probably Blocking]]></title>
            <link>https://danholloran.me/posts/bfcache-the-instant-back-button-your-site-is-probably-blocking</link>
            <guid isPermaLink="false">https://danholloran.me/posts/bfcache-the-instant-back-button-your-site-is-probably-blocking</guid>
            <pubDate>Sun, 01 Nov 2026 10:15:35 GMT</pubDate>
            <description><![CDATA[The back/forward cache restores pages from memory instantly, but an unload listener or no-store header can opt you out. Here's how to find and fix the blockers.]]></description>
            <content:encoded><![CDATA[<p>You can spend weeks shaving 200ms off your Largest Contentful Paint and still lose to a feature you never touched. Chrome's usage data shows that about 1 in 10 desktop navigations and 1 in 5 mobile navigations are a back or forward button press. For those navigations, the browser can skip the network entirely and restore the page from memory, scroll position, JavaScript heap and all. That's the back/forward cache, or bfcache, and it ships in Chrome, Firefox, and Safari.</p>
<p>The catch is that the browser decides whether your page is <em>eligible</em>, and a lot of ordinary code makes it ineligible. Nothing errors. Nothing logs. The back button just feels like a fresh page load, and you never find out why.</p>
<h2 id="how-bfcache-differs-from-the-http-cache" tabindex="-1">How bfcache differs from the HTTP cache <a class="header-anchor" href="#how-bfcache-differs-from-the-http-cache" aria-label="Permalink to &quot;How bfcache differs from the HTTP cache&quot;">&ZeroWidthSpace;</a></h2>
<p>The HTTP cache stores responses. bfcache stores the whole live page. When a user navigates away, the browser freezes the document instead of destroying it: pending timers and unresolved promises are paused, and the task queues stop processing. If the user comes back soon, the browser unpauses everything and shows the page as it was.</p>
<p>That's why a bfcache restore beats even a perfectly tuned HTTP-cached load. There's no parsing, no script execution, no hydration. It also means your code needs to cope with a page that &quot;wakes up&quot; rather than loads. The <code>pageshow</code> event is your hook for that, and its <code>persisted</code> property tells you which kind of show it was:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">window</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">pageshow</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">persisted</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">    // Restored from bfcache: the DOM and JS state are exactly as the user left them.</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    refreshCartBadge</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    refreshRelativeTimestamps</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>That snippet also covers the one real downside. A restored page skips revalidation, so <code>Cache-Control: no-cache</code> doesn't apply. If something like a cart count or a signed-in state could be stale, refresh it in <code>pageshow</code>.</p>
<h2 id="the-usual-blockers" tabindex="-1">The usual blockers <a class="header-anchor" href="#the-usual-blockers" aria-label="Permalink to &quot;The usual blockers&quot;">&ZeroWidthSpace;</a></h2>
<p>Most ineligible pages trip on one of a short list of causes.</p>
<p><strong><code>unload</code> listeners.</strong> This is the big one. Firefox on desktop refuses to cache any page with an <code>unload</code> handler, and Chrome has deprecated the event entirely, so in Chrome your handler mostly won't even run. Use <code>pagehide</code> instead, which fires every time <code>unload</code> would and also when the page enters bfcache:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Before: blocks bfcache in Firefox, unreliable everywhere</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">window</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">unload</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> flushAnalytics</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// After: works with bfcache in every browser</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">window</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">pagehide</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> flushAnalytics</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span></code></pre>
</div><p>The trap is that the <code>unload</code> listener is often not yours. An old analytics snippet or a third-party widget can add one. In Chromium you can forbid it outright with a response header, so nothing on the page can register one:</p>
<div class="language-http vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">http</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Permissions-Policy</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> unload=()</span></span></code></pre>
</div><p><strong><code>Cache-Control: no-store</code> on the document.</strong> Browsers have historically kept <code>no-store</code> pages out of bfcache. Chrome now lets some of them in, with a shorter three-minute timeout and eviction if cookies change, but other browsers still block. Reserve <code>no-store</code> for pages with sensitive data. If you just want fresh content, <code>no-cache</code> or <code>max-age=0</code> makes the browser revalidate normal loads without costing you bfcache.</p>
<p><strong>Open connections.</strong> Some browsers won't cache a page holding an open IndexedDB connection, an in-flight <code>fetch()</code>, or a WebRTC connection, because pausing it could stall other tabs on the same origin. Chrome (as of 149) and Safari no longer block on open WebSockets, but other browsers still do. Close those connections in <code>pagehide</code> and reopen them in <code>pageshow</code>:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> socket</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> connect</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  socket </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> WebSocket</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">wss://example.com/live</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  socket</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">message</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> handleMessage</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">window</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">pagehide</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> socket</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">?.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">close</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">());</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">window</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">pageshow</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">  // Normal loads connect elsewhere; only reconnect on a bfcache restore.</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">persisted</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> connect</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">connect</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span></code></pre>
</div><p><strong><code>window.opener</code> references.</strong> A page that opened a window it still controls, or that was opened with an opener reference, can't be cached safely. <code>rel=&quot;noopener&quot;</code> is the default for <code>target=&quot;_blank&quot;</code> links in modern browsers, so this usually only bites apps that use <code>window.open()</code> plus <code>postMessage</code>.</p>
<p><strong>Unconditional <code>beforeunload</code>.</strong> It no longer blocks bfcache in modern browsers, but it's still unreliable. Attach it only while there are unsaved changes and remove it once they're saved.</p>
<h2 id="find-out-why-you-re-not-getting-cached" tabindex="-1">Find out why you're not getting cached <a class="header-anchor" href="#find-out-why-you-re-not-getting-cached" aria-label="Permalink to &quot;Find out why you're not getting cached&quot;">&ZeroWidthSpace;</a></h2>
<p>Start in DevTools: <strong>Application → Back/forward cache → Run test</strong>. Chrome navigates away and back, then lists every blocker, and marks the ones you can fix as <strong>Actionable</strong>. Lighthouse runs the same check as its bfcache audit.</p>
<p>Lab tests only catch what happens on your machine, though. For real users, Chrome (from version 123) exposes <code>notRestoredReasons</code> on the navigation timing entry. It's still marked experimental and is Chromium-only, so treat it as a diagnostic, not a feature:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">nav</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> performance</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getEntriesByType</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">navigation</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">nav</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">?.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">type</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">back_forward</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> &#x26;&#x26;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> nav</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">notRestoredReasons</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> reasons</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> nav</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">notRestoredReasons</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">reasons</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">reason</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  sendToAnalytics</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">bfcache_miss</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> url</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> location</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">pathname</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> reasons </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>One subtlety: <code>notRestoredReasons</code> is <code>null</code> for non-history navigations and <code>undefined</code> where unsupported, and a <code>null</code> value doesn't prove the page was restored. Use <code>pageshow</code>'s <code>persisted</code> to count actual hits. Also expect a hit rate below 100% even on a perfect page. Browsers evict cached pages to save memory, and a restarted browser or a duplicated tab never uses bfcache.</p>
<p>One more measurement gotcha: bfcache restores replace some of your fastest page loads, so your field load metrics can look <em>worse</em> after you fix eligibility, even though users are getting faster navigations. Segment your metrics by navigation type, and use the <code>web-vitals</code> library, which already reports bfcache restores as their own visits.</p>
<h2 id="the-takeaway" tabindex="-1">The takeaway <a class="header-anchor" href="#the-takeaway" aria-label="Permalink to &quot;The takeaway&quot;">&ZeroWidthSpace;</a></h2>
<p>bfcache is the rare performance win that's mostly deletion: remove the <code>unload</code> listener, drop <code>no-store</code> where it isn't protecting anything, and close connections on <code>pagehide</code>. Run the DevTools test on your three most-visited pages today, since a single blocker on a high-traffic template is usually the whole story. The <a href="https://web.dev/articles/bfcache" target="_blank" rel="noreferrer">web.dev bfcache guide</a> and Chrome's <a href="https://developer.chrome.com/docs/web-platform/bfcache-notrestoredreasons" target="_blank" rel="noreferrer">notRestoredReasons docs</a> cover the long tail.</p>
]]></content:encoded>
            <category>web-performance</category>
            <category>performance</category>
            <category>javascript</category>
            <category>web-apis</category>
            <enclosure url="https://danholloran.me/images/posts/bfcache-the-instant-back-button-your-site-is-probably-blocking.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Laravel 13 JSON:API Resources: Spec-Compliant Responses Without a Package]]></title>
            <link>https://danholloran.me/posts/laravel-13-jsonapi-resources-spec-compliant-responses-without-a-package</link>
            <guid isPermaLink="false">https://danholloran.me/posts/laravel-13-jsonapi-resources-spec-compliant-responses-without-a-package</guid>
            <pubDate>Thu, 29 Oct 2026 09:14:46 GMT</pubDate>
            <description><![CDATA[Laravel 13 ships a first-party JsonApiResource that handles type/id objects, includes, sparse fieldsets, and Content-Type. Here's how it works and its limits.]]></description>
            <content:encoded><![CDATA[<p>Every team that builds a JSON API eventually has the same argument. Should relationships be nested or side-loaded? Is the ID a string or an integer? How does the client ask for fewer fields? You can settle it in a wiki page, or you can adopt <a href="https://jsonapi.org/" target="_blank" rel="noreferrer">JSON:API</a>, a spec that already answered all of those questions. The catch in Laravel has always been that adopting the spec meant adopting a package, plus that package's opinions about routing, schemas, and controllers.</p>
<p>Laravel 13 removes that catch. It ships <code>JsonApiResource</code>, a first-party resource class that extends the <code>JsonResource</code> you already know and produces spec-compliant output. It handles resource objects, relationships, <code>include</code>, sparse fieldsets, links, meta, and the <code>application/vnd.api+json</code> header. Your existing resources keep working, so you can adopt it one endpoint at a time.</p>
<h2 id="from-model-to-resource-object" tabindex="-1">From Model to Resource Object <a class="header-anchor" href="#from-model-to-resource-object" aria-label="Permalink to &quot;From Model to Resource Object&quot;">&ZeroWidthSpace;</a></h2>
<p>Generate one with the new <code>--json-api</code> flag:</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">php</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> artisan</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> make:resource</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> PostResource</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> --json-api</span></span></code></pre>
</div><p>The generated class gives you two properties instead of a <code>toArray</code> method:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">&#x3C;?</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">php</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">namespace</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> App</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Http</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Resources</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Illuminate</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Http</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Resources</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">JsonApi</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">JsonApiResource</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">class</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> PostResource</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> extends</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> JsonApiResource</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    public</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">attributes </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">title</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">body</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">created_at</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    ];</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    public</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">relationships </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">author</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> UserResource</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::class,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">comments</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    ];</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Return it the same way you return any resource, with <code>new PostResource($post)</code> or <code>$post-&gt;toResource()</code>, and the response comes back in the shape the spec requires:</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">attributes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">title</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Hello World</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">body</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">This is my first post.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">created_at</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">2026-10-28T14:02:11.000000Z</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Look at what you didn't write. The <code>id</code> is cast to a string, which the spec requires and hand-rolled APIs often get wrong. The <code>type</code> comes from the class name, so <code>PostResource</code> becomes <code>posts</code> and <code>BlogPostResource</code> becomes <code>blog_posts</code>. When those defaults are wrong, for example an <code>AuthorResource</code> wrapping a <code>User</code> model or a public UUID instead of the primary key, override <code>toType()</code> and <code>toId()</code>:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">public</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> toType</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> string</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">authors</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">public</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> toId</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> string</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">this</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">uuid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>If you need computed values, override <code>toAttributes()</code> instead of using the property. Closures in that array are evaluated lazily, so an expensive attribute only runs when it actually ends up in the response:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">public</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> toAttributes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> array</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">title</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">this</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">title</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">is_published</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> fn</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">this</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">published_at</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> !==</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> null</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">reading_time</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> fn</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">this</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">calculateReadingTime</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    ];</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><h2 id="includes-and-sparse-fieldsets-come-free" tabindex="-1">Includes and Sparse Fieldsets Come Free <a class="header-anchor" href="#includes-and-sparse-fieldsets-come-free" aria-label="Permalink to &quot;Includes and Sparse Fieldsets Come Free&quot;">&ZeroWidthSpace;</a></h2>
<p>This is where the class earns its place. Relationships are <strong>not</strong> serialized by default. They only appear when the client asks for them:</p>
<div class="language- vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang"></span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span>GET /api/posts/1?include=author,comments</span></span></code></pre>
</div><p>The <code>data</code> object then gets a <code>relationships</code> key holding identifier objects (<code>{ &quot;id&quot;: &quot;1&quot;, &quot;type&quot;: &quot;users&quot; }</code>), and the full related records go into a top-level <code>included</code> array, so each one appears once no matter how many posts reference it. Nested paths use dot notation (<code>?include=comments.author</code>). Nesting depth is capped by default, and you can raise or lower the cap with <code>JsonApiResource::maxRelationshipDepth(3)</code> in a service provider.</p>
<p>Sparse fieldsets work the same way. A mobile list view that only needs titles can ask for them, per resource type:</p>
<div class="language- vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang"></span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span>GET /api/posts?fields[posts]=title,created_at&#x26;fields[users]=name</span></span></code></pre>
</div><p>You don't write any parsing code for either of these. For an internal endpoint where you want fixed output whatever the query string says, chain <code>-&gt;ignoreFieldsAndIncludesInQueryString()</code> onto the resource.</p>
<h2 id="what-it-doesn-t-do-for-you" tabindex="-1">What It Doesn't Do for You <a class="header-anchor" href="#what-it-doesn-t-do-for-you" aria-label="Permalink to &quot;What It Doesn't Do for You&quot;">&ZeroWidthSpace;</a></h2>
<p><code>JsonApiResource</code> is a <strong>serializer</strong>. It shapes the response. It doesn't build your query, and that leaves two gaps you have to close yourself.</p>
<p>First, eager loading. The resource resolves a relationship when the client includes it, but the docs make no promise that it batch-loads that relationship across a collection. On a list endpoint, assume <code>?include=author</code> can turn into one query per post unless you load it up front. The safe pattern is to eager-load from an allowlist built from the same query parameter:</p>
<div class="language-php vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">php</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> App</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Models</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">use</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Illuminate</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Http</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">\</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Route</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/api/posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">allowed </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">author</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">comments</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">comments.author</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">];</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">includes </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> array_values</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">array_intersect</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">        explode</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">request</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">query</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">include</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ''</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">))</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        $</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">allowed</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    ));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Post</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">::</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">with</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">($</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">includes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">latest</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">limit</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">20</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toResourceCollection</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>Turn on <code>Model::preventLazyLoading()</code> in development and the first missed eager load will throw instead of quietly slowing down production.</p>
<p>Second, filtering and sorting. JSON:API reserves <code>filter</code> and <code>sort</code> parameters, but Laravel's resource ignores them. The Laravel docs point to <a href="https://spatie.be/docs/laravel-query-builder" target="_blank" rel="noreferrer">Spatie's Laravel Query Builder</a> for that side. It also gives you <code>allowedIncludes()</code>, which replaces the hand-written allowlist above.</p>
<p>If you also need full spec compliance on writes (validating <code>application/vnd.api+json</code> request bodies, relationship endpoints, error objects), a full framework like <a href="https://laraveljsonapi.io/" target="_blank" rel="noreferrer">Laravel JSON:API</a> still covers more ground. The first-party class only handles reads.</p>
<h2 id="should-you-switch" tabindex="-1">Should You Switch? <a class="header-anchor" href="#should-you-switch" aria-label="Permalink to &quot;Should You Switch?&quot;">&ZeroWidthSpace;</a></h2>
<p>If your API already has consumers built around your current response shape, don't migrate it just for tidiness. The shape <em>is</em> the contract, and changing it is a breaking change for every client. Where <code>JsonApiResource</code> pays off is a new API, a new version prefix, or a public API where third-party clients will thank you for following a spec they already have tooling for. Start with one endpoint, put <code>Model::preventLazyLoading()</code> in place, and read the <a href="https://laravel.com/docs/13.x/eloquent-resources#jsonapi-resources" target="_blank" rel="noreferrer">JSON:API resources section of the Laravel docs</a> for the <code>toLinks()</code> and <code>toMeta()</code> hooks this post skipped.</p>
]]></content:encoded>
            <category>laravel</category>
            <category>php</category>
            <category>web-apis</category>
            <enclosure url="https://danholloran.me/images/posts/laravel-13-jsonapi-resources-spec-compliant-responses-without-a-package.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Nuxt SSR Streaming: Faster TTFB and the Response You Can't Change Later]]></title>
            <link>https://danholloran.me/posts/nuxt-ssr-streaming-faster-ttfb-and-the-response-you-cant-change-later</link>
            <guid isPermaLink="false">https://danholloran.me/posts/nuxt-ssr-streaming-faster-ttfb-and-the-response-you-cant-change-later</guid>
            <pubDate>Mon, 26 Oct 2026 14:40:13 GMT</pubDate>
            <description><![CDATA[Nuxt 4.5 can flush your HTML shell before the page finishes rendering. Here's what experimental SSR streaming speeds up and the status code rule you need first.]]></description>
            <content:encoded><![CDATA[<p>Classic server rendering in Nuxt is all or nothing. The server runs every plugin, resolves every awaited <code>useFetch</code>, renders the whole Vue tree to a string, and only then sends a single byte back. If one API call on the page takes 600ms, the browser sits staring at a blank tab for 600ms, unable to even start downloading your CSS. Streaming SSR has been one of the longest-standing open issues in the Nuxt repo, and as of Nuxt 4.5 it finally ships behind a flag.</p>
<p>The win is real, but it comes with a contract change that is easy to trip over. Once the first byte is on the wire, the HTTP status and headers are locked in. Let's look at both halves.</p>
<h2 id="turning-it-on" tabindex="-1">Turning it on <a class="header-anchor" href="#turning-it-on" aria-label="Permalink to &quot;Turning it on&quot;">&ZeroWidthSpace;</a></h2>
<p>Streaming is experimental and off by default. Enable it in <code>nuxt.config.ts</code>:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> defineNuxtConfig</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    ssrStreaming</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>With this on, Nuxt immediately flushes an HTML shell (your <code>&lt;head&gt;</code>, styles, preload hints, and entry scripts) and then streams the body as Vue renders it. The browser can start fetching CSS, fonts, and the JS entry chunk while your server is still waiting on that slow API.</p>
<p>Bots and crawlers are excluded by default, so Googlebot and friends still get a fully buffered response. If you have an internal crawler or a link-preview service that chokes on streamed HTML, you can widen the match:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> defineNuxtConfig</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    ssrStreaming</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      botRegex</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">googlebot</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">|</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">bingbot</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">|</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">slackbot</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">|</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">my-internal-crawler</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">i</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>Be realistic about what improves. Time to First Byte drops reliably, because the shell no longer waits on your data. Largest Contentful Paint <em>may</em> improve, depending on how much of the route's JavaScript overlaps the entry chunk and whether your LCP element sits behind the slow fetch. A page whose hero is rendered from an awaited API call still has to wait for that call before the hero arrives; you just stop wasting that time on idle network.</p>
<h2 id="the-rule-the-response-is-committed-at-byte-one" tabindex="-1">The rule: the response is committed at byte one <a class="header-anchor" href="#the-rule-the-response-is-committed-at-byte-one" aria-label="Permalink to &quot;The rule: the response is committed at byte one&quot;">&ZeroWidthSpace;</a></h2>
<p>HTTP sends the status line and headers before the body. A buffered renderer can change its mind right up until the end. A streaming renderer cannot. Anything that tries to mutate the response <em>after</em> rendering has begun simply can't reach the client.</p>
<p>That covers more code than you might expect:</p>
<div class="language-vue vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">vue</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">&#x3C;!-- pages/products/[slug].vue --></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> setup</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> lang=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">ts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> route</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useRoute</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> product</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useFetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/api/products/</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">route</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">params</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">slug</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">product</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">  // Too late under streaming: a 200 has already been sent</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  setResponseStatus</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">useRequestEvent</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 404</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Also too late: the Set-Cookie header is already gone</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> lastViewed</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useCookie</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">last-viewed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">lastViewed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> String</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">route</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">params</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">slug</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>The release notes call out <code>setResponseStatus()</code> in <code>&lt;script setup&gt;</code> and cookie writes during middleware as typical cases. Nuxt does not fail silently here: in development it logs a warning naming each dropped mutation. Treat those warnings as bugs, not noise.</p>
<p>Nuxt also handles the common cases for you. Routes with <code>redirect</code>, <code>cache</code>, <code>isr</code>, <code>swr</code>, <code>noScripts</code>, or <code>ssr: false</code> route rules automatically fall back to the buffered renderer, so caching and redirect setups keep working.</p>
<h2 id="living-with-it-opt-out-where-the-response-matters" tabindex="-1">Living with it: opt out where the response matters <a class="header-anchor" href="#living-with-it-opt-out-where-the-response-matters" aria-label="Permalink to &quot;Living with it: opt out where the response matters&quot;">&ZeroWidthSpace;</a></h2>
<p>The pragmatic approach is to stream the routes where the response is boring (marketing pages, docs, blog posts, dashboards behind auth) and keep buffering the ones that need to set status codes or cookies based on rendered data. Per-route opt-out uses <code>routeRules</code>:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> defineNuxtConfig</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    ssrStreaming</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  routeRules</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/products/**</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> streaming</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> false</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/checkout/**</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> streaming</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> false</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>For logic that doesn't depend on rendered output, move it earlier instead of opting out. Nitro server middleware runs before the renderer starts, so headers and cookies set there are still in time:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// server/middleware/visitor-id.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> defineEventHandler</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getCookie</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">visitor-id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">))</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    setCookie</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">visitor-id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> crypto</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">randomUUID</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      httpOnly</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      sameSite</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">lax</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      maxAge</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 60</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> *</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 60</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> *</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 24</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> *</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 365</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>A quick checklist before you flip the flag on a real app:</p>
<ol>
<li>Search for <code>setResponseStatus</code>, <code>setHeader</code>, <code>setResponseHeader</code>, and cookie writes in <code>pages/</code>, <code>layouts/</code>, <code>components/</code>, and route middleware.</li>
<li>Run the app in dev with streaming on and click through your key routes, watching for dropped-mutation warnings.</li>
<li>Decide per route: move the logic into server middleware, or add <code>streaming: false</code>.</li>
<li>Check that 404 pages still return 404 (curl with <code>-I</code> is enough), because a soft 404 is an SEO problem you won't see in the browser.</li>
</ol>
<h2 id="where-this-leaves-you" tabindex="-1">Where this leaves you <a class="header-anchor" href="#where-this-leaves-you" aria-label="Permalink to &quot;Where this leaves you&quot;">&ZeroWidthSpace;</a></h2>
<p>SSR streaming is the kind of feature that rewards a careful rollout: turn it on, read the dev warnings, opt out the handful of routes that need control of their response, and measure TTFB and LCP before and after rather than assuming. Because it is still experimental, expect the details to shift as the Nuxt team works toward v5. The <a href="https://nuxt.com/blog/v4-5" target="_blank" rel="noreferrer">Nuxt 4.5 release post</a> and the <a href="https://github.com/nuxt/nuxt/pull/34411" target="_blank" rel="noreferrer">original pull request</a> are the best places to track the fallback rules and caveats as they evolve.</p>
]]></content:encoded>
            <category>nuxt.js</category>
            <category>vue</category>
            <category>performance</category>
            <category>node</category>
            <enclosure url="https://danholloran.me/images/posts/nuxt-ssr-streaming-faster-ttfb-and-the-response-you-cant-change-later.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[JSON.parse Source Text Access: Stop Losing Precision on Big IDs]]></title>
            <link>https://danholloran.me/posts/json-parse-source-text-access-stop-losing-precision-on-big-ids</link>
            <guid isPermaLink="false">https://danholloran.me/posts/json-parse-source-text-access-stop-losing-precision-on-big-ids</guid>
            <pubDate>Sun, 25 Oct 2026 09:05:43 GMT</pubDate>
            <description><![CDATA[JSON.parse can now hand your reviver the original source text of numbers, and JSON.rawJSON writes it back exactly. Here's how to round-trip big IDs.]]></description>
            <content:encoded><![CDATA[<p>Your API returns an order ID of <code>9007199254740993</code>. Your frontend logs <code>9007199254740992</code>. Nobody touched it. <code>JSON.parse</code> quietly rounded it, because every JSON number becomes a JavaScript double, and doubles only hold integers exactly up to <code>Number.MAX_SAFE_INTEGER</code> (<code>9007199254740991</code>). Anything bigger gets snapped to the nearest representable value before your code ever sees it.</p>
<p>The usual workarounds are all a little sad. Ask the backend to send IDs as strings (<code>id_str</code>, if you remember the old Twitter API). Pull in a third-party parser like <code>json-bigint</code>. Or run a regex over the raw response to wrap long numbers in quotes before parsing, and hope nothing in a string field looks like a number. The platform now has a real fix: the JSON.parse source text access proposal, which adds a <code>context</code> argument to revivers and a matching <code>JSON.rawJSON()</code> for serialization. It reached Stage 4 at TC39 in late 2025, and it is already Baseline: Chrome 114, Firefox 135, Safari 18.4, Node 21, Deno 1.33, and Bun 1.1.43 all ship it.</p>
<h2 id="the-reviver-gets-a-third-argument" tabindex="-1">The reviver gets a third argument <a class="header-anchor" href="#the-reviver-gets-a-third-argument" aria-label="Permalink to &quot;The reviver gets a third argument&quot;">&ZeroWidthSpace;</a></h2>
<p>A reviver has always received <code>(key, value)</code>. It now receives <code>(key, value, context)</code>, and for primitive values <code>context.source</code> is the exact slice of JSON text that produced <code>value</code>. The number was still parsed into a lossy double, but you get the original digits next to it, so you can decide what it should become.</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> json</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">{"orderId": 9007199254740993, "total": 42.5}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> order</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> JSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">parse</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">json</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">orderId</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">    // value is already rounded; context.source is not</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> BigInt</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">source</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">order</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">orderId</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // 9007199254740993n</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">order</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">total</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // 42.5</span></span></code></pre>
</div><p>Keying off property names works when you control the schema, but a generic version is more useful in a shared fetch helper. Promote only integers that actually lost precision, and leave everything else alone:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> INTEGER</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">^</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">-</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">\d</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">+$</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">/</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> preciseReviver</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    typeof</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">number</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> &#x26;&#x26;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    !</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Number</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">isSafeInteger</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> &#x26;&#x26;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">?.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">source</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> &#x26;&#x26;</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    INTEGER</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">source</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  )</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> BigInt</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">source</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> res</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> fetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/api/orders</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> data</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> JSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">parse</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> preciseReviver</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span></code></pre>
</div><p>Two details there matter. <code>Response.json()</code> doesn't accept a reviver, so you read the body as text and parse it yourself. And the optional chaining on <code>context?.source</code> is deliberate: <code>context.source</code> only exists for primitives. When the reviver is called for an object or array, <code>source</code> is absent, because there's no single meaningful slice of text to give you once the children have already been revived.</p>
<h2 id="writing-it-back-out-with-json-rawjson" tabindex="-1">Writing it back out with JSON.rawJSON <a class="header-anchor" href="#writing-it-back-out-with-json-rawjson" aria-label="Permalink to &quot;Writing it back out with JSON.rawJSON&quot;">&ZeroWidthSpace;</a></h2>
<p>Parsing is half the problem. Hand that <code>BigInt</code> to <code>JSON.stringify</code> and you get <code>TypeError: Do not know how to serialize a BigInt</code>. Convert it to a <code>Number</code> first and you're back to rounding. <code>JSON.rawJSON()</code> closes the loop: it wraps a piece of JSON text that <code>stringify</code> will emit verbatim.</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> preciseReplacer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> typeof</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">bigint</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ?</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> JSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">rawJSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toString</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">())</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> :</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">JSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">stringify</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> orderId</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 9007199254740993</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">n</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> preciseReplacer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// '{"orderId":9007199254740993}'</span></span></code></pre>
</div><p>The output is a real JSON number, not a quoted string, so a Go or Java backend expecting an <code>int64</code> reads it without any schema change. <code>JSON.rawJSON</code> is strict about what it accepts: the text must be valid JSON for a primitive, so <code>JSON.rawJSON('[1, 2]')</code> or a string containing a raw line break throws a <code>SyntaxError</code>. The returned object is frozen with a null prototype, and <code>JSON.isRawJSON()</code> tells you if you're holding one.</p>
<h2 id="decimals-not-just-big-integers" tabindex="-1">Decimals, not just big integers <a class="header-anchor" href="#decimals-not-just-big-integers" aria-label="Permalink to &quot;Decimals, not just big integers&quot;">&ZeroWidthSpace;</a></h2>
<p>Money is the other place this pays off. <code>0.1</code> parsed as a double is not exactly one tenth, and a payments API that returns <code>&quot;amount&quot;: 19.99</code> hands you a value that's already slightly wrong before your decimal library sees it. With <code>context.source</code> you can feed the original digits straight into whatever arbitrary-precision type you use:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Decimal </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">decimal.js</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> invoice</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> JSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">parse</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">body</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  key </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">amount</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ?</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Decimal</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">source</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> :</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span></code></pre>
</div><p>On the way out, <code>JSON.rawJSON(amount.toFixed(2))</code> writes <code>19.99</code> as an unquoted number again.</p>
<h2 id="when-not-to-reach-for-it" tabindex="-1">When not to reach for it <a class="header-anchor" href="#when-not-to-reach-for-it" aria-label="Permalink to &quot;When not to reach for it&quot;">&ZeroWidthSpace;</a></h2>
<p>A reviver runs once per value in the document, so it is measurably slower than a plain <code>JSON.parse</code> on large payloads. If your IDs are already strings, or every number fits in the safe range, you don't need any of this. In TypeScript, older <code>lib</code> definitions may not know about the third reviver argument or <code>JSON.rawJSON</code> yet, so you might need a small ambient declaration until your <code>lib</code> setting catches up. For older browsers, <code>core-js</code> ships a polyfill for both halves.</p>
<p>The bigger change is where the fix lives. Precision used to be something you negotiated with the backend team or patched with a dependency. Now it's a few lines in the one fetch helper every request already goes through. MDN's pages on <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse" target="_blank" rel="noreferrer"><code>JSON.parse()</code></a> and <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/rawJSON" target="_blank" rel="noreferrer"><code>JSON.rawJSON()</code></a> have the full reference, and the <a href="https://github.com/tc39/proposal-json-parse-with-source" target="_blank" rel="noreferrer">TC39 proposal</a> covers the design reasoning.</p>
]]></content:encoded>
            <category>javascript</category>
            <category>web-apis</category>
            <category>node</category>
            <category>json</category>
            <enclosure url="https://danholloran.me/images/posts/json-parse-source-text-access-stop-losing-precision-on-big-ids.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Subpath Imports With #/: The Path Alias Node Actually Understands]]></title>
            <link>https://danholloran.me/posts/subpath-imports-with-the-path-alias-node-actually-understands</link>
            <guid isPermaLink="false">https://danholloran.me/posts/subpath-imports-with-the-path-alias-node-actually-understands</guid>
            <pubDate>Thu, 22 Oct 2026 09:16:26 GMT</pubDate>
            <description><![CDATA[Node 24.14 and TypeScript 6.0 allow a bare #/ prefix in package.json imports. Here's how one alias can replace tsconfig paths, Vite aliases, and build rewrites.]]></description>
            <content:encoded><![CDATA[<p>Count the places your <code>@/</code> import alias is defined. There's <code>paths</code> in <code>tsconfig.json</code> so the editor stops complaining. There's <code>resolve.alias</code> in <code>vite.config.ts</code> so the dev server can find the file. If you run tests, there's probably <code>vite-tsconfig-paths</code> or a Jest <code>moduleNameMapper</code> doing the same job a third time. And if any of that code ever runs directly in Node, none of those configs help, because Node has never read a tsconfig in its life.</p>
<p>That arrangement got less comfortable this year. TypeScript 6.0 deprecated <code>baseUrl</code>, and the TypeScript 7 native compiler drops it entirely. Meanwhile the platform quietly fixed the thing that kept people from using the standard alternative. Node's <code>package.json</code> <code>imports</code> field now accepts a bare <code>#/</code> prefix, and TypeScript 6.0 understands it. You can have one alias, defined once, that every tool in the chain reads from the same place.</p>
<h2 id="what-actually-changed" tabindex="-1">What actually changed <a class="header-anchor" href="#what-actually-changed" aria-label="Permalink to &quot;What actually changed&quot;">&ZeroWidthSpace;</a></h2>
<p>Subpath imports have existed in Node since v14. The <code>imports</code> field in <code>package.json</code> lets a package define private specifiers that only resolve from inside that package. The rule is that every key must start with <code>#</code>, so nothing can collide with a real package name on npm.</p>
<p>The catch was that <code>#/</code> on its own was not a valid key. You had to write <code>#src/*</code> or <code>#lib/*</code> or <code>#root/*</code>, which always felt like inventing a name to satisfy a parser. People coming from bundlers wanted the <code>@/</code> feel, tried <code>#/</code>, got a resolution error, and went back to <code>paths</code>.</p>
<p>Node fixed that in v25.4.0 and backported it to v24.14.0, per the version history in the <a href="https://nodejs.org/api/packages.html#subpath-imports" target="_blank" rel="noreferrer">Node.js packages docs</a>. TypeScript 6.0 added matching support under <code>moduleResolution</code> set to <code>nodenext</code> or <code>bundler</code>. So this is now legal:</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">name</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">my-app</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">module</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">imports</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">#/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./src/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> formatPrice </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">#/utils/format.js</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useCart </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">#/composables/useCart.js</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span></code></pre>
</div><p>The <code>*</code> is plain string replacement, so <code>#/utils/format.js</code> becomes <code>./src/utils/format.js</code>, nested folders included. TypeScript applies its usual <code>.js</code> to <code>.ts</code> lookup on the result, the same way it does for relative imports.</p>
<h2 id="migrating-a-vite-app-off-paths" tabindex="-1">Migrating a Vite app off <code>paths</code> <a class="header-anchor" href="#migrating-a-vite-app-off-paths" aria-label="Permalink to &quot;Migrating a Vite app off `paths`&quot;">&ZeroWidthSpace;</a></h2>
<p>For a typical Vue or React project built with Vite, the move is mostly deletion. Add the <code>imports</code> field above, then strip the duplicates:</p>
<div class="language-diff vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">diff</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> // tsconfig.json</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">   "compilerOptions": {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">     "moduleResolution": "bundler",</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    "baseUrl": ".",</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    "paths": {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">      "@/*": ["./src/*"]</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">   }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> }</span></span></code></pre>
</div><div class="language-diff vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">diff</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> // vite.config.ts</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> export default defineConfig({</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">   plugins: [vue()],</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  resolve: {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  },</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> })</span></span></code></pre>
</div><p>A current Vite release resolves the <code>imports</code> field natively, and Vitest inherits Vite's resolver, so the test config needs nothing extra. SvelteKit is already heading this way: the docs for its next version scaffold a <code>#lib</code> entry in <code>package.json</code> instead of the old built-in <code>$lib</code> alias.</p>
<p>Then rewrite the specifiers. Because the shape is identical, a find-and-replace from <code>'@/</code> to <code>'#/</code> covers most of a codebase. Run <code>tsc --noEmit</code> and your test suite afterward, since that will catch any file that relied on extensionless imports your bundler was forgiving about.</p>
<h2 id="node-services-and-the-dist-question" tabindex="-1">Node services and the <code>dist</code> question <a class="header-anchor" href="#node-services-and-the-dist-question" aria-label="Permalink to &quot;Node services and the `dist` question&quot;">&ZeroWidthSpace;</a></h2>
<p>The bigger win is code that Node runs directly. A <code>paths</code> alias in a Node service was always a trap: <code>tsc</code> happily type-checks <code>@/db/client</code>, emits it unchanged into <code>dist</code>, and the first run crashes with <code>ERR_MODULE_NOT_FOUND</code>. The usual fix was another tool, like <code>tsc-alias</code> or <code>tsconfig-paths</code>, to rewrite or patch resolution after the fact.</p>
<p>With subpath imports, Node itself is the resolver. Point the alias at your build output:</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">module</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">imports</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">#/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./dist/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>That is the exact example in the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/" target="_blank" rel="noreferrer">TypeScript 6.0 announcement</a>. With <code>rootDir</code> and <code>outDir</code> set explicitly, TypeScript maps the <code>dist</code> path back to the matching source file in <code>src</code> for type-checking, while Node resolves the emitted JavaScript at runtime. No post-build rewrite step.</p>
<p>If you run TypeScript directly with Node's type stripping and never build, map to source instead and write your imports with the real <code>.ts</code> extension. Either way, the alias lives in one file that Node reads.</p>
<h2 id="the-tradeoffs" tabindex="-1">The tradeoffs <a class="header-anchor" href="#the-tradeoffs" aria-label="Permalink to &quot;The tradeoffs&quot;">&ZeroWidthSpace;</a></h2>
<p>A few things to know before you commit to it:</p>
<ul>
<li><strong>Version floor.</strong> <code>#/</code> needs Node 24.14+ or 25.4+. On Node 22, or anything older, you still need a named prefix like <code>#src/*</code>. Pin <code>engines</code> in <code>package.json</code> so nobody discovers this in CI.</li>
<li><strong>Tooling lag.</strong> Every resolver has to implement the <code>#/</code> rule. The oxc resolver needed an explicit change for it, and older Jest, ESLint import plugins, or editor extensions may not have caught up. Check yours before a big rename.</li>
<li><strong>Private by design.</strong> <code>imports</code> only applies inside the package that declares it. In a monorepo, each workspace package gets its own aliases. It will not let <code>apps/web</code> reach into <code>packages/ui/src</code>, and that's a feature.</li>
<li><strong>It's <code>#</code>, not <code>@</code>.</strong> Some teams will find this ugly for a week. Then it's just the alias.</li>
</ul>
<p>If your tsconfig still has <code>baseUrl</code> and a <code>paths</code> block, you have to touch it before TypeScript 7 anyway. That's the moment to move the alias somewhere Node, Vite, Vitest, and TypeScript all agree on. Start with the <a href="https://nodejs.org/api/packages.html#subpath-imports" target="_blank" rel="noreferrer">Node.js subpath imports docs</a>, add one line to <code>package.json</code>, and delete three config blocks.</p>
]]></content:encoded>
            <category>typescript</category>
            <category>node</category>
            <category>tooling</category>
            <category>bundlers</category>
            <enclosure url="https://danholloran.me/images/posts/subpath-imports-with-the-path-alias-node-actually-understands.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[React Fragment Refs: Attach Behavior Without the Wrapper Div]]></title>
            <link>https://danholloran.me/posts/react-fragment-refs-attach-behavior-without-a-wrapper-div</link>
            <guid isPermaLink="false">https://danholloran.me/posts/react-fragment-refs-attach-behavior-without-a-wrapper-div</guid>
            <pubDate>Mon, 19 Oct 2026 09:15:13 GMT</pubDate>
            <description><![CDATA[React 19.3 made Fragment refs stable. Here's how FragmentInstance lets you listen, observe, and move focus across a group of elements without a wrapper div.]]></description>
            <content:encoded><![CDATA[<p>Every React codebase has at least one of these: a <code>&lt;div&gt;</code> that exists for no reason except to hold a ref. Maybe it wraps a list so an <code>IntersectionObserver</code> has something to watch. Maybe it wraps a group of form fields so you can focus the first one. It works, right up until it breaks a flex layout, adds a stray grid item, or lands inside a <code>&lt;ul&gt;</code> where only <code>&lt;li&gt;</code> belongs.</p>
<p>The other version of this problem is worse. You want to observe or focus a component from a library, and that component doesn't forward its <code>ref</code>. Now you're either forking the library or adding the wrapper div anyway.</p>
<p>React 19.3, released on September 9, 2026, made <strong>Fragment refs</strong> stable. You can now pass a <code>ref</code> to <code>&lt;Fragment&gt;</code> and get back a <code>FragmentInstance</code>, an object with a small set of DOM methods that operate on the fragment's children as a group. No wrapper, no changes to the children.</p>
<h2 id="what-a-fragmentinstance-can-do" tabindex="-1">What a FragmentInstance can do <a class="header-anchor" href="#what-a-fragmentinstance-can-do" aria-label="Permalink to &quot;What a FragmentInstance can do&quot;">&ZeroWidthSpace;</a></h2>
<p>The setup is the ref you already know, just on a Fragment. One catch: the <code>&lt;&gt;...&lt;/&gt;</code> shorthand can't take props, so you need the explicit <code>Fragment</code> import.</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useRef </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Toolbar</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> children </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> groupRef</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">null</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ref=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">groupRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">children</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Fragment</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p><code>groupRef.current</code> is now a <code>FragmentInstance</code>. Its methods fall into four groups:</p>
<ul>
<li><strong>Events:</strong> <code>addEventListener</code>, <code>removeEventListener</code>, and <code>dispatchEvent</code></li>
<li><strong>Focus:</strong> <code>focus</code>, <code>focusLast</code>, and <code>blur</code></li>
<li><strong>Observers:</strong> <code>observeUsing</code> and <code>unobserveUsing</code>, which take an <code>IntersectionObserver</code> or <code>ResizeObserver</code></li>
<li><strong>Layout:</strong> <code>getClientRects</code>, <code>getRootNode</code>, <code>compareDocumentPosition</code>, and <code>scrollIntoView</code></li>
</ul>
<p>The important detail is <em>which</em> nodes these touch. Event, observer, and layout methods target the fragment's <strong>first-level DOM children</strong>. React looks through component boundaries to find them, so if a child component renders a <code>&lt;section&gt;</code>, that <code>&lt;section&gt;</code> is a target. But it stops at the first real DOM node: an element nested inside that <code>&lt;section&gt;</code> is not. Focus methods are the exception. <code>focus()</code> and <code>focusLast()</code> search depth-first through everything, which is what you want when the inputs are buried inside a <code>&lt;fieldset&gt;</code> and a <code>&lt;label&gt;</code>.</p>
<p>Listeners are also kept in sync. If children are added or removed later, the <code>FragmentInstance</code> attaches or detaches the listener for you.</p>
<h2 id="observing-visibility-with-no-wrapper-and-no-forwarded-refs" tabindex="-1">Observing visibility with no wrapper and no forwarded refs <a class="header-anchor" href="#observing-visibility-with-no-wrapper-and-no-forwarded-refs" aria-label="Permalink to &quot;Observing visibility with no wrapper and no forwarded refs&quot;">&ZeroWidthSpace;</a></h2>
<p>This is the case that used to force the wrapper div. Here is a reusable <code>InView</code> component that reports whether any of its children are on screen:</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useLayoutEffect</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useRef </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> InView</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> onChange</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> children </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> fragmentRef</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">null</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  useLayoutEffect</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> visible</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> observer</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> IntersectionObserver</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entries</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> entry</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> of</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> entries</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">isIntersecting</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> visible</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">add</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">target</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        else</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> visible</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">delete</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">target</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">      onChange</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">visible</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">size</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    });</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> fragmentRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">current</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    fragment</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">observeUsing</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">observer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> fragment</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">unobserveUsing</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">observer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">onChange</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ref=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">fragmentRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">children</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Fragment</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Use it around anything, including third-party components that never heard of refs:</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">InView</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> onChange=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">setHeroVisible</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">VendorBanner</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">PricingTable</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">InView</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p><code>VendorBanner</code> and <code>PricingTable</code> render exactly the DOM they always did. Your CSS grid still sees two items, not one wrapper. If <code>onChange</code> is an inline function, wrap it in <code>useCallback</code> (or let the React Compiler handle it) so the observer isn't torn down on every render.</p>
<p>One warning from the docs: <code>observeUsing</code> doesn't work on text nodes, and React logs a dev warning if a fragment contains only text.</p>
<h2 id="focus-management-for-groups" tabindex="-1">Focus management for groups <a class="header-anchor" href="#focus-management-for-groups" aria-label="Permalink to &quot;Focus management for groups&quot;">&ZeroWidthSpace;</a></h2>
<p>Focus is the other place this pays off, and it's an accessibility win. When a disclosure panel opens, a dialog-like section mounts, or a validation error appears, you usually want to move focus into it. Before, that meant a ref on the specific input, which meant threading refs through whatever components sat in between.</p>
<div class="language-jsx vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">jsx</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useEffect</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> useRef </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">react</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> StepPanel</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> isActive</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> children </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> stepRef</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">null</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  useEffect</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">isActive</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> stepRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">current</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">focus</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> preventScroll</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">isActive</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Fragment</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ref=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">stepRef</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">children</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Fragment</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p><code>focus()</code> finds the first focusable element anywhere inside the children, and <code>focusLast()</code> finds the last one. That pair is most of what you need for a simple focus loop at the edges of a section. <code>blur()</code> only does something if the active element is actually inside the fragment, so it's safe to call without checking first.</p>
<p>Two small gotchas. <code>scrollIntoView</code> takes only a boolean (<code>true</code> aligns the first child to the top, <code>false</code> aligns the last child to the bottom), and passing a <code>ScrollIntoViewOptions</code> object throws. And listeners added through <code>addEventListener</code> are not applied to children inside a hidden <code>&lt;Activity&gt;</code> boundary until it becomes visible again.</p>
<h2 id="when-to-reach-for-it" tabindex="-1">When to reach for it <a class="header-anchor" href="#when-to-reach-for-it" aria-label="Permalink to &quot;When to reach for it&quot;">&ZeroWidthSpace;</a></h2>
<p>Fragment refs aren't a replacement for regular refs. If you own the element and it's a single node, a plain <code>ref</code> on that element is still simpler. Reach for a Fragment ref when you need to attach behavior to a <em>group</em> of siblings, or to components you don't control, and a wrapper element would cost you layout, semantics, or both.</p>
<p>If you're already on React 19.3, try deleting one wrapper div this week. The <a href="https://react.dev/reference/react/Fragment" target="_blank" rel="noreferrer"><code>&lt;Fragment&gt;</code> reference</a> covers every method, and the <a href="https://react.dev/blog/2026/09/09/react-19-3" target="_blank" rel="noreferrer">React 19.3 release post</a> walks through View Transitions and the new <code>browser()</code> API that shipped alongside it.</p>
]]></content:encoded>
            <category>react.js</category>
            <category>react</category>
            <category>javascript</category>
            <category>accessibility</category>
            <enclosure url="https://danholloran.me/images/posts/react-fragment-refs-attach-behavior-without-a-wrapper-div.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[CSS Gap Decorations: Divider Lines Without the Border Hacks]]></title>
            <link>https://danholloran.me/posts/css-gap-decorations-divider-lines-without-the-border-hacks</link>
            <guid isPermaLink="false">https://danholloran.me/posts/css-gap-decorations-divider-lines-without-the-border-hacks</guid>
            <pubDate>Sun, 18 Oct 2026 09:14:46 GMT</pubDate>
            <description><![CDATA[Chrome and Edge 149 let column-rule and row-rule draw lines in grid and flexbox gaps, so you can delete the border and pseudo-element tricks you maintain.]]></description>
            <content:encoded><![CDATA[<p>You have a three-column card grid and the design calls for thin lines between the cards. Not around them, between them. So you reach for the usual tricks: a <code>border-right</code> on every card except the last in each row, an <code>:nth-child(3n)</code> selector to remove it, a matching <code>border-bottom</code> that has to vanish on the final row, and a media query that rewrites all of it when the grid drops to two columns. Or you give up and fake it with a background color showing through a <code>1px</code> gap.</p>
<p>The <code>gap</code> property has handled spacing in grid and flexbox for years, but the space itself was never stylable. As of Chrome and Edge 149, it is. CSS gap decorations take the <code>column-rule</code> property you may remember from multi-column layout, make it work in grid and flexbox, and add a <code>row-rule</code> partner for the other axis.</p>
<h2 id="the-basics-column-rule-row-rule-and-rule" tabindex="-1">The basics: <code>column-rule</code>, <code>row-rule</code>, and <code>rule</code> <a class="header-anchor" href="#the-basics-column-rule-row-rule-and-rule" aria-label="Permalink to &quot;The basics: `column-rule`, `row-rule`, and `rule`&quot;">&ZeroWidthSpace;</a></h2>
<p>If you've used <code>border</code>, you already know the syntax. Each rule takes a width, a style, and a color:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">cards</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> grid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  grid-template-columns:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> repeat</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">auto-fill</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> minmax</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">16</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">fr</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  gap:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  column-rule:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">d4d4d8</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  row-rule</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">d4d4d8</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>When both axes use the same value, the <code>rule</code> shorthand sets them together:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">cards</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  rule:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">d4d4d8</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>The lines are drawn in the middle of each gap and follow the layout wherever it goes. Change the column count, let <code>auto-fill</code> reflow the grid at a narrower viewport, or add a tenth card, and the decorations move with it. There are no selectors that assume a fixed row length, and nothing gets added to the DOM or the accessibility tree. Decorations are purely visual and don't change layout, so turning them on never shifts anything.</p>
<p>Flexbox works the same way. A wrapping nav with dashed separators is one declaration:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">nav</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> ul</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> flex</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  flex-wrap:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> wrap</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  gap:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1.5</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  column-rule:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dashed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">71717a</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><h2 id="patterns-and-per-gap-styles" tabindex="-1">Patterns and per-gap styles <a class="header-anchor" href="#patterns-and-per-gap-styles" aria-label="Permalink to &quot;Patterns and per-gap styles&quot;">&ZeroWidthSpace;</a></h2>
<p>Rules accept comma-separated lists that cycle across gaps, plus a <code>repeat()</code> syntax borrowed from grid tracks. That makes alternating styles trivial. Here's a calendar where hour boundaries are solid and half-hours are dashed:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">calendar</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> grid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  row-gap:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0.5</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  row-rule-width</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  row-rule-style</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dashed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  row-rule-color</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">e4e4e7</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">f4f4f5</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>And a page-level grid where the first divider is heavy and every one after it is thin, however many sections you add:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">body</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> grid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  gap:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 4</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  row-rule</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    0.5</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">e4e4e7</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    repeat</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">auto</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">e4e4e7</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p><code>repeat(auto, ...)</code> is the important bit. It fills however many gaps remain, so the rule list never needs updating when content grows.</p>
<h2 id="fine-tuning-insets-breaks-and-empty-cells" tabindex="-1">Fine-tuning: insets, breaks, and empty cells <a class="header-anchor" href="#fine-tuning-insets-breaks-and-empty-cells" aria-label="Permalink to &quot;Fine-tuning: insets, breaks, and empty cells&quot;">&ZeroWidthSpace;</a></h2>
<p>The defaults look reasonable, but the real win is the control layer on top. A few properties carry most of the weight.</p>
<p><strong>Insets</strong> shrink lines away from their ends. <code>column-rule-inset: 1rem</code> pulls each vertical line in from the top and bottom, which is the &quot;separator that doesn't touch the edges&quot; look from a media card. For more precision, <code>rule-inset-cap</code> targets the outer ends of a line and <code>rule-inset-junction</code> targets the points where row and column gaps meet. The <code>overlap-join</code> keyword joins rules neatly at intersections while keeping the outer ends flush.</p>
<p><strong>Breaks</strong> decide what happens at intersections. <code>rule-break: intersection</code> cuts lines where gaps cross, giving you separate segments per cell. <code>rule-break: none</code> draws continuous lines straight through.</p>
<p><strong>Visibility</strong> solves the dangling-line problem. A grid whose last row is only partly filled would otherwise draw rules next to empty cells. <code>rule-visibility-items: between</code> only paints a rule when there are items on both sides, and <code>around</code> paints it when at least one side has an item.</p>
<p><strong>Overlap</strong> sets which axis wins where they cross. With a thick column rule and a thin row rule, <code>rule-overlap: column-over-row</code> puts the vertical lines on top.</p>
<p>Put together, a dashboard grid with clean joins and no stray lines looks like this:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">dashboard</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> grid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  grid-template-columns:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> repeat</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">fr</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  gap:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1.5</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  rule:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">3f3f46</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  rule-inset-cap</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  rule-inset-junction</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0.5</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">rem</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  rule-visibility-items</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> between</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  transition:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> rule-inset-junction </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">s</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">dashboard</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">hover</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  rule-inset-junction</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Yes, that transition works. Rule width, color, and insets are all animatable.</p>
<h2 id="shipping-it-today" tabindex="-1">Shipping it today <a class="header-anchor" href="#shipping-it-today" aria-label="Permalink to &quot;Shipping it today&quot;">&ZeroWidthSpace;</a></h2>
<p>This is a Chromium feature right now: Chrome and Edge 149 and other browsers built on the same engine. Firefox and Safari haven't shipped it yet, so it isn't Baseline. A polyfill is in development from Microsoft, but most uses don't need one. In a browser without support, the gaps simply render as plain space, which is usually a perfectly good fallback for a decorative line.</p>
<p>If a missing divider would actually hurt readability, gate a fallback with <code>@supports</code>:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">cards</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> *</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  border-bottom:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">d4d4d8</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">supports</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">row-rule</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">cards</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> *</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    border-bottom:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> none</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">cards</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    rule:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> #</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">d4d4d8</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    rule-visibility-items</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> between</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>One heads-up if you tried the developer trial back in Chrome 139: some names changed before release. <code>column-rule-outset</code> became <code>column-rule-inset</code>, the paint-order property is now <code>rule-overlap</code>, and the visibility properties are new. Old demos and blog posts may use the earlier names.</p>
<p>Treat gap decorations as progressive enhancement and start deleting those <code>:nth-child</code> border rules on the next layout you touch. The <a href="https://developer.chrome.com/blog/gap-decorations-stable" target="_blank" rel="noreferrer">Chrome stable announcement</a> covers every property, and Microsoft's <a href="https://microsoftedge.github.io/Demos/css-gap-decorations/playground.html" target="_blank" rel="noreferrer">interactive playground</a> is the fastest way to get a feel for insets and breaks before you write any CSS of your own.</p>
]]></content:encoded>
            <category>css</category>
            <category>animation</category>
            <enclosure url="https://danholloran.me/images/posts/css-gap-decorations-divider-lines-without-the-border-hacks.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Svelte Error Boundaries: Contain the Crash Instead of Losing the Page]]></title>
            <link>https://danholloran.me/posts/svelte-error-boundaries-contain-the-crash-instead-of-losing-the-page</link>
            <guid isPermaLink="false">https://danholloran.me/posts/svelte-error-boundaries-contain-the-crash-instead-of-losing-the-page</guid>
            <pubDate>Thu, 15 Oct 2026 09:15:22 GMT</pubDate>
            <description><![CDATA[One flaky widget shouldn't blank your whole app. Here's how Svelte's boundary element walls off failures, shows loading UI for await, and works during SSR.]]></description>
            <content:encoded><![CDATA[<p>Every app has that one component. The chart that chokes on a <code>null</code> in the data, the third-party embed that throws when its script loads late, the dashboard card that assumes an API field will always exist. In a lot of Svelte apps, one of those throwing during render takes the whole page down with it, and your users get a blank screen instead of a page that's 95% fine.</p>
<p>Svelte 5 has had a fix for this since 5.3: <code>&lt;svelte:boundary&gt;</code>. It has quietly grown into one of the more useful special elements in the framework, and as of 5.51 it can even handle errors during server-side rendering. If you haven't reached for it yet, here's what it does and where it fits.</p>
<h2 id="walling-off-a-component" tabindex="-1">Walling Off a Component <a class="header-anchor" href="#walling-off-a-component" aria-label="Permalink to &quot;Walling Off a Component&quot;">&ZeroWidthSpace;</a></h2>
<p>A boundary wraps part of your tree. If anything inside throws while rendering or while running an effect, the boundary catches it, removes the broken content, and renders a <code>failed</code> snippet in its place. The snippet receives the error and a <code>reset</code> function that rebuilds the contents from scratch.</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> RevenueChart </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./RevenueChart.svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">RevenueChart</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> failed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> reset</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">div</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> class=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">card card--error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">The revenue chart couldn't load.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">button</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> onclick=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">reset</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Try again</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">button</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">div</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>The rest of the page keeps rendering normally. Only the chart's slot shows the fallback, and <code>reset</code> gives the user a way to retry without a full reload.</p>
<p>If you'd rather report the error than (or as well as) render something, pass an <code>onerror</code> handler. It gets the same <code>error</code> and <code>reset</code> arguments:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  import</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> *</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> as</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Sentry </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@sentry/svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ActivityFeed </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./ActivityFeed.svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> onerror=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Sentry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">captureException</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">ActivityFeed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> failed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Activity is unavailable right now.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>If <code>onerror</code> itself throws, or you rethrow, the error bubbles to the nearest parent boundary. That lets you build layers: a tight boundary around each risky widget, and a coarse one near the root as the last line of defense.</p>
<h2 id="what-boundaries-don-t-catch" tabindex="-1">What Boundaries Don't Catch <a class="header-anchor" href="#what-boundaries-don-t-catch" aria-label="Permalink to &quot;What Boundaries Don't Catch&quot;">&ZeroWidthSpace;</a></h2>
<p>This is the part that trips people up. Boundaries catch errors that happen <strong>during rendering and inside effects</strong>. They do not catch errors in event handlers, inside a <code>setTimeout</code>, or in other async work that runs outside the render cycle.</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">  &#x3C;!-- This throw is NOT caught by the boundary --></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">button</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> onclick=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> throw</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">nope</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }}></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Click</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">button</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> failed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()}&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Never shown for the click error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>That's the same split React draws, and for the same reason: an event handler failing doesn't leave the UI in a half-rendered state, so there's nothing for a boundary to replace. Handle those errors where they happen, with <code>try/catch</code> and some local state.</p>
<h2 id="pending-ui-for-await" tabindex="-1">Pending UI for <code>await</code> <a class="header-anchor" href="#pending-ui-for-await" aria-label="Permalink to &quot;Pending UI for `await`&quot;">&ZeroWidthSpace;</a></h2>
<p>Boundaries also handle the other side of async rendering. With Svelte's <code>await</code> expressions, you can put an <code>await</code> directly in your markup, and a boundary's <code>pending</code> snippet shows while those expressions first resolve:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  let</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> userId </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">props</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">h2</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> getUser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">userId</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)).</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">name</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">h2</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> getStats</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">userId</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)).</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> pending</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">div</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> class=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">skeleton</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> aria-busy=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">true</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">div</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> failed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> reset</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">button</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> onclick=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">reset</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Couldn't load profile. Retry?</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">button</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>Two caveats. First, <code>pending</code> only shows on the <strong>initial</strong> load. Later updates keep the old content on screen, and you use <code>$effect.pending()</code> if you want a spinner for those. Second, during server-side rendering, a boundary with a <code>pending</code> snippet renders only that snippet and skips its content, so put boundaries around the slow, non-critical parts of the page and not around the content you need indexed.</p>
<h2 id="boundaries-on-the-server" tabindex="-1">Boundaries on the Server <a class="header-anchor" href="#boundaries-on-the-server" aria-label="Permalink to &quot;Boundaries on the Server&quot;">&ZeroWidthSpace;</a></h2>
<p>Until recently, boundaries did nothing during SSR. An error in any component failed the whole <code>render()</code> call. Svelte 5.51 changed that with a <code>transformError</code> option on <code>render</code> from <code>svelte/server</code>:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> render </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">svelte/server</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> App </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./App.svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> head</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> body</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> render</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">App</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  transformError</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">    // Log the real error, with its stack, on the server...</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    console</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">    // ...and send only a safe, serializable shape to the browser</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> message</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Something went wrong loading this section.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>When a component inside a boundary with a <code>failed</code> snippet throws during SSR, <code>transformError</code> decides what the snippet receives. The return value has to be JSON-serializable because it gets serialized into the page and used to hydrate the <code>failed</code> snippet in the browser. If the boundary also has <code>onerror</code>, it fires on hydration with that deserialized object. If <code>transformError</code> throws, the whole render fails as before.</p>
<p>The redaction step matters. Server errors can carry database messages, file paths, and stack traces, and the Svelte docs explicitly recommend sanitizing them rather than shipping them to the client.</p>
<p>If you're on SvelteKit, you don't call <code>render</code> yourself, so the framework has to wire <code>transformError</code> up for you. The Svelte docs say that support is coming via SvelteKit's <code>handleError</code> hook. Check the SvelteKit changelog for your version before you count on server-side boundaries working there. Until then, <code>handleError</code> and <code>+error.svelte</code> remain the SvelteKit way to deal with errors in <code>load</code> and on the server.</p>
<h2 id="where-to-put-them" tabindex="-1">Where to Put Them <a class="header-anchor" href="#where-to-put-them" aria-label="Permalink to &quot;Where to Put Them&quot;">&ZeroWidthSpace;</a></h2>
<p>A good rule: wrap anything whose failure shouldn't cost the user the rest of the page. Third-party embeds, data visualizations, user-generated content renderers, and any widget fed by a flaky API are all prime candidates. Keep boundaries out of trivial components, because a fallback for every button is noise. Start with one root-level boundary plus a few around your riskiest widgets, wire <code>onerror</code> into whatever error tracking you already use, and read the <a href="https://svelte.dev/docs/svelte/svelte-boundary" target="_blank" rel="noreferrer"><code>&lt;svelte:boundary&gt;</code> docs</a> for the full API.</p>
]]></content:encoded>
            <category>svelte</category>
            <category>javascript</category>
            <category>tooling</category>
            <enclosure url="https://danholloran.me/images/posts/svelte-error-boundaries-contain-the-crash-instead-of-losing-the-page.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Dependency Cooldowns: Make Your Package Manager Wait Before It Trusts New Code]]></title>
            <link>https://danholloran.me/posts/dependency-cooldowns-make-your-package-manager-wait-before-it-trusts-new-code</link>
            <guid isPermaLink="false">https://danholloran.me/posts/dependency-cooldowns-make-your-package-manager-wait-before-it-trusts-new-code</guid>
            <pubDate>Mon, 12 Oct 2026 09:15:05 GMT</pubDate>
            <description><![CDATA[npm, pnpm, Yarn, and Bun can all refuse package versions that are only hours old. Here's how each release-age gate works, why units differ, and where CI bites.]]></description>
            <content:encoded><![CDATA[<p>Most npm supply-chain attacks follow the same script. Someone phishes a maintainer or steals a publish token, pushes a poisoned patch release, and then waits for every <code>^1.2.3</code> range on the planet to pick it up on the next install. The malicious version usually gets spotted and pulled within hours. The damage is done in those hours, by machines that installed it before anyone noticed.</p>
<p>A dependency cooldown attacks that window directly. Instead of installing whatever was published thirty seconds ago, your package manager only accepts versions that have existed for a while. It's a boring idea, and that's why it works: you let the rest of the ecosystem be the canary. Every major package manager now supports it, but each one picked its own config key and, annoyingly, its own unit of time.</p>
<h2 id="one-idea-four-spellings" tabindex="-1">One idea, four spellings <a class="header-anchor" href="#one-idea-four-spellings" aria-label="Permalink to &quot;One idea, four spellings&quot;">&ZeroWidthSpace;</a></h2>
<p>Here's the same &quot;wait at least a day&quot; policy in each tool.</p>
<p>pnpm uses minutes, configured in <code>pnpm-workspace.yaml</code>. Since pnpm 11 this is <strong>on by default</strong> at 1440 minutes, so if you're on a current pnpm you already have it:</p>
<div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"># pnpm-workspace.yaml</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">minimumReleaseAge</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1440</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> # minutes</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">minimumReleaseAgeExclude</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  -</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@myorg/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span></code></pre>
</div><p>npm added <code>min-release-age</code> in CLI 11.10, measured in <strong>days</strong> and <strong>off by default</strong>:</p>
<div class="language-ini vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ini</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"># .npmrc</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">min-release-age=</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">1</span></span></code></pre>
</div><p>Bun uses <strong>seconds</strong>, under the <code>[install]</code> table:</p>
<div class="language-toml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">toml</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"># bunfig.toml</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">[</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">install</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">minimumReleaseAge </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 86400</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> # seconds</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">minimumReleaseAgeExcludes </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">@types/node</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">typescript</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span></span></code></pre>
</div><p>Yarn Berry (4.10+) calls it <code>npmMinimalAgeGate</code>, in minutes:</p>
<div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"># .yarnrc.yml</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">npmMinimalAgeGate</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1440</span></span></code></pre>
</div><p>Minutes, days, seconds, minutes. If you maintain a repo that switches package managers, or you copy a snippet from a blog post written for a different tool, it's very easy to set a 1440-<em>day</em> cooldown in npm or a 24-<em>second</em> one in Bun. Leave a comment with the unit next to the number every time.</p>
<h2 id="the-details-that-actually-matter" tabindex="-1">The details that actually matter <a class="header-anchor" href="#the-details-that-actually-matter" aria-label="Permalink to &quot;The details that actually matter&quot;">&ZeroWidthSpace;</a></h2>
<p>The headline setting is simple. The edge cases are where teams get surprised.</p>
<p><strong>It applies to transitive dependencies too.</strong> That's the point: the attack rarely lands in a package you depend on directly. pnpm's docs are explicit that the gate covers the whole tree, which also means a deep dependency like <code>caniuse-lite</code> (which publishes constantly) can be the thing that trips it.</p>
<p><strong>Strict vs. fallback.</strong> In pnpm, if nothing in your requested range is old enough, there are two possible behaviors: fail, or fall back to the too-new version. pnpm's built-in default of 1440 is non-strict for backward compatibility, so it quietly falls back. The moment you set <code>minimumReleaseAge</code> yourself, <code>minimumReleaseAgeStrict</code> flips to <code>true</code> and the install fails instead. That's the behavior you want for security, but be ready for it.</p>
<p><strong>Lockfiles behave differently per tool.</strong> Bun only applies the gate during new resolution, so entries already in <code>bun.lock</code> are left alone. pnpm goes further: by default it re-checks every lockfile entry against the policy on install, which catches a lockfile someone generated under a weaker setting. You can turn that off with <code>trustLockfile: true</code>, but only do it if every commit comes from people you trust.</p>
<p><strong>Missing timestamps pass by default.</strong> The gate relies on the registry's <code>time</code> metadata. Some private registries and mirrors don't serve it, and both pnpm and Bun treat an undated version as passing. If you run an internal proxy, pnpm lets you tighten this:</p>
<div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">minimumReleaseAgeIgnoreMissingTime</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> false</span></span></code></pre>
</div><p><strong>npm's config output can lie to you.</strong> There's an open npm CLI issue where <code>npm config ls --long</code> reports <code>min-release-age = null</code> even though <code>npm install</code> is honoring the setting, because npm converts it to a <code>--before</code> date internally. Test the behavior with an install, not by reading the config dump.</p>
<h2 id="where-it-bites-and-how-to-live-with-it" tabindex="-1">Where it bites, and how to live with it <a class="header-anchor" href="#where-it-bites-and-how-to-live-with-it" aria-label="Permalink to &quot;Where it bites, and how to live with it&quot;">&ZeroWidthSpace;</a></h2>
<p>The most common complaint after pnpm 11 shipped wasn't about attacks. It was Dependabot. A bot opens a PR the moment a new version appears, CI runs a frozen install, and the brand-new version is younger than the cooldown, so CI goes red. The PR isn't wrong, it's just early. Your options are to delay the bot (Dependabot and Renovate both support a minimum age or cooldown on their side), or add a narrow, version-pinned exclusion:</p>
<div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">minimumReleaseAgeExclude</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  -</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> nx@21.6.5</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  -</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> webpack@4.47.0 || 5.102.1</span></span></code></pre>
</div><p>Pinning to a specific version beats excluding a whole package name. A name-level exclusion means every future release of that package skips the gate, which is exactly the hole an attacker wants. pnpm can even clean up stale exclusions for you with <code>minimumReleaseAgeExcludePrune: true</code>.</p>
<p>The other real cost is urgent security patches. When a CVE fix lands and you need it now, the cooldown is in your way. npm lets you override per command:</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">npm</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> i</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> --min-release-age</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> some-package@1.4.2</span></span></code></pre>
</div><p>Use that deliberately, for the one package, and don't bake it into scripts.</p>
<p>Finally, be honest about what this protects you from. A cooldown only helps against compromises that get caught quickly. A patient attacker who ships something that sits undetected for weeks walks right through it. It's one layer, alongside lockfiles, <code>allowBuilds</code> / install-script controls, and provenance checks like pnpm's <code>trustPolicy: no-downgrade</code>.</p>
<p>If you're on pnpm 11 or newer, you've already got a soft version of this. Set <code>minimumReleaseAge</code> explicitly so it becomes strict, add a cooldown to your update bot so it stops racing your own policy, and if you're on npm, add <code>min-release-age=1</code> to your <code>.npmrc</code> today. It's one line, and it turns &quot;we installed the bad version&quot; into &quot;we read about the bad version.&quot;</p>
]]></content:encoded>
            <category>tooling</category>
            <category>node</category>
            <category>javascript</category>
            <enclosure url="https://danholloran.me/images/posts/dependency-cooldowns-make-your-package-manager-wait-before-it-trusts-new-code.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[CSS Container Style Queries: Let the Parent Set the Theme]]></title>
            <link>https://danholloran.me/posts/css-container-style-queries-let-the-parent-set-the-theme</link>
            <guid isPermaLink="false">https://danholloran.me/posts/css-container-style-queries-let-the-parent-set-the-theme</guid>
            <pubDate>Sun, 11 Oct 2026 09:15:43 GMT</pubDate>
            <description><![CDATA[Container style queries hit Baseline in May 2026. Here's how to let a parent's custom property drive its children's styles, and the one gotcha to watch for.]]></description>
            <content:encoded><![CDATA[<p>Every design system eventually grows a modifier class problem. The card needs a dark version for the footer, a compact version for the sidebar, and a &quot;highlighted&quot; version for the pricing page. So you add <code>.card--dark</code>, <code>.card--compact</code>, <code>.card--featured</code>, and then you need a dark <em>compact</em> card, and then the button inside the card also needs to know it's on a dark background. Now the class has to be threaded through three components, or you're writing selectors like <code>.card--dark .button</code>.</p>
<p>Container size queries solved half of this in 2023: components could finally react to the space they're given instead of the viewport. The other half, reacting to the <em>context</em> they're placed in, became Baseline Newly available in May 2026 when Firefox 151 shipped support for container style queries. Chrome and Edge have had them since 111, and Safari since 18, so this is ready for production with a sensible fallback.</p>
<h2 id="how-style-queries-work" tabindex="-1">How Style Queries Work <a class="header-anchor" href="#how-style-queries-work" aria-label="Permalink to &quot;How Style Queries Work&quot;">&ZeroWidthSpace;</a></h2>
<p>A style query asks a question about the computed value of a custom property on an ancestor. If the answer is yes, the nested rules apply to descendants:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">panel</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  --theme</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dark</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> style(--theme: dark) </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">card</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    background:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> oklch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.22</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0.02</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 260</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    color:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> oklch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.95</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">button</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    border-color:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> currentColor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Two things are different from size queries. First, you don't need <code>container-type</code>. Every element is a style container by default, so <code>@container style(...)</code> checks the nearest ancestor with no setup at all. Second, only custom properties work today. The spec allows <code>style(font-weight: bold)</code> on regular properties, but no browser implements that yet, so stick to <code>--anything</code>.</p>
<p>If you have nested contexts (a light card inside a dark panel inside a light page), name the level you care about:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">site-footer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  container-name:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> footer</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  --theme</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dark</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> footer style(--theme: dark) </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">newsletter-form</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> input</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    background:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> transparent</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    color:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> inherit</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Without a name, the query targets the nearest ancestor. With one, it targets the nearest ancestor with that <code>container-name</code>, which is what you want when themes nest.</p>
<h2 id="the-gotcha-you-can-t-query-yourself" tabindex="-1">The Gotcha: You Can't Query Yourself <a class="header-anchor" href="#the-gotcha-you-can-t-query-yourself" aria-label="Permalink to &quot;The Gotcha: You Can't Query Yourself&quot;">&ZeroWidthSpace;</a></h2>
<p>This is the mistake almost everyone makes on day one:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">/* Does not work the way you'd expect */</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">card</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">[</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">data-variant=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">featured</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  --variant</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> featured</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> style(--variant: featured) </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">card</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    border:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> gold</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>The query is evaluated against the card's <em>ancestor</em>, not the card. The card's parent never set <code>--variant</code>, so the query fails. Custom properties inherit downward, not upward. The fix is to style a child of the element that holds the value, which in practice means having a wrapper:</p>
<div class="language-html vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">article</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> class=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">card</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> style=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">--variant: featured</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">div</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> class=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">card__inner</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">h3</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Pro plan</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">h3</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">a</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> class=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">button</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> href=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/signup</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Start trial</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">a</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">div</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">article</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> style(--variant: featured) </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">card__inner</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    border:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> solid</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> gold</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">button</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    background:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> gold</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    color:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> black</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>If you only need a single property on the element itself to change, that's what the <code>if()</code> function is for. A useful rule of thumb: <code>if()</code> produces a conditional value for one element, while a style query sets context for a whole subtree.</p>
<p>It's also worth registering the property with <code>@property</code>. A registered property has a known type and an initial value, so a container that never sets <code>--variant</code> resolves to something predictable instead of the guaranteed-invalid value:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">property</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> --variant </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  syntax: "&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">custom-ident</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">";</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">  initial-value</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">: default;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  inherits: true;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">}</span></span></code></pre>
</div><h2 id="range-queries-and-a-fallback-plan" tabindex="-1">Range Queries and a Fallback Plan <a class="header-anchor" href="#range-queries-and-a-fallback-plan" aria-label="Permalink to &quot;Range Queries and a Fallback Plan&quot;">&ZeroWidthSpace;</a></h2>
<p>Exact matching covers themes and variants, but numbers are where it gets interesting. Chrome 142 added range syntax, so you can compare values instead of matching strings:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">meter</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  --fill</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 72</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">%</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> style(--fill > 90%) </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">meter__bar</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    background:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> oklch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.6</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0.2</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 25</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> style(--fill > 50%) and style(--fill &#x3C;= 90%) </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">meter__bar</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    background:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> oklch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.75</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0.15</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 85</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Both sides of a comparison must resolve to the same numeric type (length, number, percentage, angle, time, frequency, or resolution). Note the split here: plain equality style queries are Baseline, but range syntax is Chromium-only for now. Treat range queries as a progressive enhancement.</p>
<p>The fallback story is simple because style queries are additive. Write your default component styles outside any query, then layer the contextual variants inside <code>@container style()</code> blocks. A browser that doesn't understand the at-rule skips it entirely and renders the default card. Nothing breaks; the footer card is just light instead of dark. If that's not acceptable for a given component, keep the old modifier class as a fallback alongside the style query and delete it once your analytics say the stragglers are gone.</p>
<h2 id="where-to-start" tabindex="-1">Where to Start <a class="header-anchor" href="#where-to-start" aria-label="Permalink to &quot;Where to Start&quot;">&ZeroWidthSpace;</a></h2>
<p>Pick the one place in your codebase where a theme or variant class gets passed down through props or descendant selectors, and replace it with a single custom property on the wrapper and a style query for the children. You'll likely find that the components inside stop needing to know anything about where they live. The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_style_queries" target="_blank" rel="noreferrer">MDN guide to container size and style queries</a> covers the full syntax, and Una Kravets' <a href="https://una.im/range-style-queries" target="_blank" rel="noreferrer">range style queries write-up</a> is worth bookmarking for when the range syntax lands everywhere.</p>
]]></content:encoded>
            <category>css</category>
            <category>web-apis</category>
            <category>container-queries</category>
            <enclosure url="https://danholloran.me/images/posts/css-container-style-queries-let-the-parent-set-the-theme.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Map.getOrInsert: Stop Writing the has/get/set Dance]]></title>
            <link>https://danholloran.me/posts/map-getorinsert-stop-writing-the-has-get-set-dance</link>
            <guid isPermaLink="false">https://danholloran.me/posts/map-getorinsert-stop-writing-the-has-get-set-dance</guid>
            <pubDate>Thu, 08 Oct 2026 09:14:43 GMT</pubDate>
            <description><![CDATA[Map and WeakMap now have getOrInsert and getOrInsertComputed, two methods that replace the check-then-set boilerplate every JavaScript codebase is full of.]]></description>
            <content:encoded><![CDATA[<p>Every JavaScript codebase has this pattern somewhere. Usually in a dozen places. You want to group things by a key, so you reach for a <code>Map</code>, and then you write the same three lines you always write: check if the key exists, create an empty bucket if it doesn't, then fetch the bucket and push into it.</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> byAuthor</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> post</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> of</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">byAuthor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">has</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">author</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">))</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    byAuthor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">author</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> []);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  byAuthor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">author</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">).</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">push</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>It works. It's also three hash lookups for one logical operation, and in TypeScript the <code>get()</code> call still returns <code>T | undefined</code>, so you end up sprinkling a non-null assertion on a value you just guaranteed exists. The upsert proposal fixes this with two small methods, and as of 2026 they ship in every major browser engine.</p>
<h2 id="getorinsert-and-getorinsertcomputed" tabindex="-1">getOrInsert and getOrInsertComputed <a class="header-anchor" href="#getorinsert-and-getorinsertcomputed" aria-label="Permalink to &quot;getOrInsert and getOrInsertComputed&quot;">&ZeroWidthSpace;</a></h2>
<p>The proposal reached Stage 4 at TC39 in January 2026 and adds two methods to both <code>Map</code> and <code>WeakMap</code>:</p>
<ul>
<li><code>map.getOrInsert(key, defaultValue)</code> returns the existing value for <code>key</code>. If there isn't one, it stores <code>defaultValue</code> and returns that.</li>
<li><code>map.getOrInsertComputed(key, callback)</code> does the same thing, but only calls <code>callback(key)</code> to build the default when the key is actually missing.</li>
</ul>
<p>The grouping loop above collapses to one line per item:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> byAuthor</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> post</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> of</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  byAuthor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getOrInsert</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">author</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> []).</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">push</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">post</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Counting works the same way. Because the method returns the stored value, you can read and write in one expression:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> wordCounts</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> word</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> of</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toLowerCase</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">match</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">/</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">\w</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">+</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">g</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ??</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [])</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  wordCounts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">word</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> wordCounts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getOrInsert</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">word</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> +</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>For simple grouping you could also reach for <code>Map.groupBy()</code>, which landed in 2024. The difference is that <code>groupBy</code> builds a new map in one shot from an iterable, while <code>getOrInsert</code> works on a map you're mutating over time: an event stream, a cache, or a registry that's filled lazily as requests come in.</p>
<h2 id="when-to-use-the-computed-variant" tabindex="-1">When to use the Computed variant <a class="header-anchor" href="#when-to-use-the-computed-variant" aria-label="Permalink to &quot;When to use the Computed variant&quot;">&ZeroWidthSpace;</a></h2>
<p>The catch with <code>getOrInsert</code> is that its default is an ordinary argument, so it's evaluated every time, even when the key already exists. Passing <code>[]</code> or <code>0</code> costs essentially nothing. Passing <code>new Set()</code> allocates on every call. Passing <code>await fetchConfig(id)</code> or <code>expensiveParse(source)</code> is a real bug.</p>
<p>MDN's own example shows it plainly: calling <code>map.getOrInsert(&quot;bar&quot;, defaultCreator(&quot;bar&quot;))</code> runs <code>defaultCreator</code> even though <code>&quot;bar&quot;</code> is already in the map, while <code>getOrInsertComputed(&quot;bar&quot;, defaultCreator)</code> doesn't run it at all.</p>
<p>So the rule of thumb is simple: use <code>getOrInsert</code> for cheap literals, and <code>getOrInsertComputed</code> whenever building the default allocates or does work. A memoization helper is the classic case:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> cache</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> getHighlighter</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">language</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> cache</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getOrInsertComputed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">language</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">lang</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    createHighlighter</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> lang</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> theme</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">github-dark</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> })</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  );</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>The callback receives the key, so you can pass a named function instead of an inline closure. It must be callable: anything else throws a <code>TypeError</code>.</p>
<h2 id="weakmap-is-where-it-really-shines" tabindex="-1">WeakMap is where it really shines <a class="header-anchor" href="#weakmap-is-where-it-really-shines" aria-label="Permalink to &quot;WeakMap is where it really shines&quot;">&ZeroWidthSpace;</a></h2>
<p>The same two methods exist on <code>WeakMap</code>, which makes per-object side data much cleaner. Say you need to attach state to DOM elements without putting properties on the nodes themselves:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> elementState</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> WeakMap</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> stateFor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">el</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> elementState</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getOrInsertComputed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">el</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    clicks</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    lastSeen</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> performance</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">now</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }));</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">document</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">addEventListener</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">click</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> state</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> stateFor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">event</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">target</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  state</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">clicks</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> +=</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>When the element is removed and garbage collected, its entry goes with it. No cleanup code, no leaks, and no &quot;is this initialized yet&quot; branch anywhere in your handlers.</p>
<h2 id="can-you-ship-it-today" tabindex="-1">Can you ship it today? <a class="header-anchor" href="#can-you-ship-it-today" aria-label="Permalink to &quot;Can you ship it today?&quot;">&ZeroWidthSpace;</a></h2>
<p>Support is recent but broad. According to caniuse, <code>getOrInsertComputed</code> is available in Chrome and Edge 145+, Firefox 144+, and Safari 26.2+ on both macOS and iOS, and MDN marks it Baseline 2026 (newly available). That still leaves a tail of users on older Safari and Samsung Internet, so for public sites you'll want a fallback for now. Both <code>core-js</code> and es-shims provide polyfills, and the hand-rolled version is tiny if you'd rather avoid a dependency:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">prototype</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">getOrInsertComputed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">  Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">prototype</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getOrInsertComputed</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> callback</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">typeof</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> callback </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!==</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">function</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      throw</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> TypeError</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">callback must be callable</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">this</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">has</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">))</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> this</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> callback</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    this</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>For server code, check your runtime before relying on it: <code>node -p &quot;typeof Map.prototype.getOrInsert&quot;</code> prints <code>function</code> when it's there. If your TypeScript <code>lib</code> setting doesn't know about the methods yet, a small declaration file bridges the gap:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">interface</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">K</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> V</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  getOrInsert</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> K</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> defaultValue</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> V</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> V</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  getOrInsertComputed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> K</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> callback</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> K</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> V</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> V</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Note that both return <code>V</code>, not <code>V | undefined</code>, which is the other quiet win here: the non-null assertions go away along with the boilerplate.</p>
<p>The upsert methods aren't flashy, but they're the kind of feature you end up using every day once you know it exists. Next time you type <code>if (!map.has(key))</code>, stop and reach for <code>getOrInsert</code> instead. The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/getOrInsertComputed" target="_blank" rel="noreferrer">MDN reference for getOrInsertComputed</a> is a good place to start.</p>
]]></content:encoded>
            <category>javascript</category>
            <category>node</category>
            <category>performance</category>
            <enclosure url="https://danholloran.me/images/posts/map-getorinsert-stop-writing-the-has-get-set-dance.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Astro Live Content Collections: Fresh CMS Data Without a Rebuild]]></title>
            <link>https://danholloran.me/posts/astro-live-content-collections-fresh-cms-data-without-a-rebuild</link>
            <guid isPermaLink="false">https://danholloran.me/posts/astro-live-content-collections-fresh-cms-data-without-a-rebuild</guid>
            <pubDate>Mon, 05 Oct 2026 09:15:44 GMT</pubDate>
            <description><![CDATA[Astro's live content collections fetch CMS and API data at request time with the same typed API as build-time collections, and route caching keeps them fast.]]></description>
            <content:encoded><![CDATA[<p>Every Jamstack site eventually hits the same wall. The marketing team fixes a typo in the CMS, hits publish, and then asks why the site still shows the old copy. The honest answer is &quot;because the site is a pile of HTML that was generated twenty minutes ago, and the rebuild is still in the queue.&quot; Webhooks, incremental builds, and on-demand revalidation all exist to paper over that gap, and each one adds another moving part you have to babysit.</p>
<p>Astro's answer is live content collections. They shipped as stable in Astro 6 and picked up first-class caching in Astro 7. The pitch is simple: keep the content collection API you already know, but fetch the data when the page is requested instead of when the site is built. No rebuild, no webhook, and the same typed <code>entry.data</code> you get from build-time collections.</p>
<h2 id="build-time-vs-live-two-configs-one-mental-model" tabindex="-1">Build-time vs. live: two configs, one mental model <a class="header-anchor" href="#build-time-vs-live-two-configs-one-mental-model" aria-label="Permalink to &quot;Build-time vs. live: two configs, one mental model&quot;">&ZeroWidthSpace;</a></h2>
<p>Build-time collections live in <code>src/content.config.ts</code>, run their loaders during <code>astro build</code>, and persist the results in Astro's data store. That is still the right default. It is fast, works with MDX, and lets Astro optimize images.</p>
<p>Live collections live in a separate file, <code>src/live.config.ts</code>, and use <code>defineLiveCollection()</code>. They require an adapter for on-demand rendering, because the whole point is that the data is fetched per request. There are no built-in live loaders, so you write one or install a community loader.</p>
<p>A live loader is an object with a <code>name</code> and two methods: <code>loadCollection()</code> for lists and <code>loadEntry()</code> for a single item. Each returns data or an <code>Error</code>, never throws.</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// src/loaders/changelog.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> LiveLoader </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">astro/loaders</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">interface</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Release</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  title</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  html</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  updatedAt</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> changelogLoader</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">opts</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  apiUrl</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  token</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> LiveLoader</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Release</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> tag</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> headers</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> Authorization</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> `</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Bearer </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">opts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">token</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> };</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    name</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">changelog-loader</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    loadCollection</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> filter </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      try</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> qs</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> filter</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">?.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">tag</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ?</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> `</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">?tag=</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">encodeURIComponent</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">filter</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">tag</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> :</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ""</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> res</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> fetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">opts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">apiUrl</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/releases</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">qs</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> headers </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> releases</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Release</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">[]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">json</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          entries</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> releases</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">map</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> data</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> r </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}))</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          cacheHint</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> tags</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">releases</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> catch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          error</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Failed to load releases</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> cause</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> error </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">    loadEntry</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> filter </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      try</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> res</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> fetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">opts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">apiUrl</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/releases/</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">filter</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">id</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">          headers</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">status</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ===</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 404</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">          return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> error</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Release not found</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> };</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> r</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Release</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">json</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          data</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> r</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          rendered</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> html</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">html</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">          cacheHint</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">            tags</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">release-</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">id</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">            lastModified</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Date</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">r</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">updatedAt</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">          }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">        };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> catch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">        return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> error</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Failed to load release</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> cause</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> error </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">      }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Then register it. The Zod schema is optional, but if you provide one it wins over the loader's generic types and validation errors come back as a typed error instead of a crash:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// src/live.config.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> defineLiveCollection </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">astro:content</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> z </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">astro/zod</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> changelogLoader </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./loaders/changelog</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> releases</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> defineLiveCollection</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  loader</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> changelogLoader</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    apiUrl</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">meta</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">env</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">CMS_URL</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    token</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">meta</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">env</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">CMS_TOKEN</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  })</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  schema</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> z</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">object</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> z</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    title</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> z</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    html</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> z</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    updatedAt</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> z</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">coerce</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">date</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  })</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> collections</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> releases </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">};</span></span></code></pre>
</div><h2 id="querying-live-data-with-explicit-errors" tabindex="-1">Querying live data with explicit errors <a class="header-anchor" href="#querying-live-data-with-explicit-errors" aria-label="Permalink to &quot;Querying live data with explicit errors&quot;">&ZeroWidthSpace;</a></h2>
<p>On the page side you swap <code>getEntry()</code> for <code>getLiveEntry()</code> and <code>getCollection()</code> for <code>getLiveCollection()</code>. The difference that matters is the return shape: you get <code>{ entry, error }</code> back rather than an exception, which forces you to decide what a CMS outage looks like for your users.</p>
<div class="language-astro vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">astro</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">---</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// src/pages/changelog/[id].astro</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> prerender</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> false</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> getLiveEntry</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> render </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">astro:content</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> LiveEntryNotFoundError </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">astro/content/runtime</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> entry</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> getLiveEntry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">releases</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Astro</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">params</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">error </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">instanceof</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> LiveEntryNotFoundError</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Astro</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">rewrite</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/404</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Response</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Changelog temporarily unavailable</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> status</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 503</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Astro</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">cache</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Astro</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">cache</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> maxAge</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 300</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> swr</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 60</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> Content</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> render</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">---</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">h1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">entry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">title</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">h1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Content</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span></code></pre>
</div><p>The error union is narrow: <code>LiveEntryNotFoundError</code>, <code>LiveCollectionValidationError</code>, <code>LiveCollectionCacheHintError</code>, or a generic <code>LiveCollectionError</code>. Because the loader returned a <code>rendered.html</code> property, <code>render()</code> and <code>&lt;Content /&gt;</code> work exactly like they do for Markdown collections.</p>
<h2 id="caching-is-what-makes-this-not-a-regression" tabindex="-1">Caching is what makes this not-a-regression <a class="header-anchor" href="#caching-is-what-makes-this-not-a-regression" aria-label="Permalink to &quot;Caching is what makes this not-a-regression&quot;">&ZeroWidthSpace;</a></h2>
<p>Fetching from a CMS on every request is slower than serving static HTML, and the Astro docs say so plainly. That is where Astro 7's route caching comes in. Configure a provider once (the in-memory one for Node, or the experimental CDN providers for Netlify, Vercel, and Cloudflare) and pass the live entry to <code>Astro.cache.set()</code>. Astro pulls the loader's cache hint off it, tagging the response and setting <code>Last-Modified</code> for you. Multiple <code>set()</code> calls merge: tags accumulate, <code>lastModified</code> takes the newest date, and <code>maxAge</code> is last-write-wins.</p>
<p>Invalidation then becomes a tiny endpoint your CMS webhook can hit:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// src/pages/api/revalidate.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> POST</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> request</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> cache </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> request</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">json</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> cache</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">invalidate</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> tags</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">release-</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">id</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Response</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">json</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> purged</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Notice the inversion. In the classic Jamstack setup, the webhook triggers a full rebuild. Here it purges one cache tag, and the next request fetches fresh data.</p>
<h2 id="when-to-stay-on-build-time-collections" tabindex="-1">When to stay on build-time collections <a class="header-anchor" href="#when-to-stay-on-build-time-collections" aria-label="Permalink to &quot;When to stay on build-time collections&quot;">&ZeroWidthSpace;</a></h2>
<p>Live collections give up a few things: no MDX rendering at runtime, no image optimization, and no persistence in the content layer data store. If your content changes a few times a week, build-time collections plus a deploy hook are still simpler and faster. Reach for live collections when freshness is the feature: inventory, pricing, changelogs, CMS draft previews, or filters driven by request parameters.</p>
<p>The nice part is you don't have to pick one for the whole site. Both kinds coexist, so your docs can stay static while the one page that always goes stale finally doesn't. Start with the <a href="https://docs.astro.build/en/guides/content-collections/#live-content-collections" target="_blank" rel="noreferrer">content collections guide</a> and the <a href="https://docs.astro.build/en/guides/caching/" target="_blank" rel="noreferrer">route caching docs</a>, and try converting your most-complained-about page first.</p>
]]></content:encoded>
            <category>jamstack</category>
            <category>javascript</category>
            <category>typescript</category>
            <category>performance</category>
            <enclosure url="https://danholloran.me/images/posts/astro-live-content-collections-fresh-cms-data-without-a-rebuild.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Nuxt createUseFetch: One Typed API Client Without the Wrapper Boilerplate]]></title>
            <link>https://danholloran.me/posts/nuxt-createusefetch-one-typed-api-client-without-the-wrapper-boilerplate</link>
            <guid isPermaLink="false">https://danholloran.me/posts/nuxt-createusefetch-one-typed-api-client-without-the-wrapper-boilerplate</guid>
            <pubDate>Sun, 04 Oct 2026 09:15:26 GMT</pubDate>
            <description><![CDATA[Nuxt 4.4 added createUseFetch and createUseAsyncData, factories that bake your base URL, auth headers, and error handling into a typed useFetch.]]></description>
            <content:encoded><![CDATA[<p>Every Nuxt app that talks to a real backend ends up with the same problem. You call <code>useFetch('/users')</code> in one page, then <code>useFetch('/orders')</code> in another, and before long every call site is repeating the same <code>baseURL</code>, the same <code>Authorization</code> header, and the same &quot;redirect to login on 401&quot; logic. So you write a wrapper. Then you discover the wrapper loses some of <code>useFetch</code>'s typing, or its generics get ugly, or you are not sure it still plays nicely with SSR deduplication.</p>
<p>Nuxt 4.4 shipped a first-party answer: <code>createUseFetch</code> and <code>createUseAsyncData</code>. They are factories that return a composable with the exact same signature as <code>useFetch</code> (or <code>useAsyncData</code>), just with your defaults baked in. No hand-rolled generics, no guessing about keys.</p>
<h2 id="the-basic-factory" tabindex="-1">The basic factory <a class="header-anchor" href="#the-basic-factory" aria-label="Permalink to &quot;The basic factory&quot;">&ZeroWidthSpace;</a></h2>
<p>The factory lives in your <code>composables/</code> directory like any other composable. Here is an API client for an external backend, adapted from the recipe in the Nuxt docs:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// app/composables/useAPI.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> useAPI</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createUseFetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  baseURL</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">https://api.example.com</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  onRequest</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> options </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> session</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useUserSession</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">session</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">?.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">token</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">      options</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">headers</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Authorization</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> `</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Bearer </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">session</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">value</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">token</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  async</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> onResponseError</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> response </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">})</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">response</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">status</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ===</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 401</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> navigateTo</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/login</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>Now pages just ask for data:</p>
<div class="language-vue vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">vue</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> setup</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> lang=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">ts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> profile</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useAPI</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/me</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> orders</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> status</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useAPI</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/orders</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> lazy</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p><code>useAPI</code> returns the same <code>data</code>, <code>status</code>, <code>error</code>, and <code>refresh</code> you already know, with the same typing. Every option <code>useFetch</code> accepts (<code>query</code>, <code>transform</code>, <code>pick</code>, <code>server</code>, <code>getCachedData</code>, and so on) is still available at the call site.</p>
<p>One rule to remember: <code>createUseFetch</code> is a <strong>compiler macro</strong>. It has to be an exported declaration in <code>composables/</code> (or another directory Nuxt scans). That is how Nuxt finds it at build time and injects the de-duplication keys that make SSR hydration reuse the server payload instead of fetching twice. Define it inside a component or a random utility file and you lose that.</p>
<h2 id="defaults-vs-overrides" tabindex="-1">Defaults vs. overrides <a class="header-anchor" href="#defaults-vs-overrides" aria-label="Permalink to &quot;Defaults vs. overrides&quot;">&ZeroWidthSpace;</a></h2>
<p>The factory has two modes, and picking the right one matters.</p>
<p><strong>Pass a plain object</strong> and your options are <em>defaults</em>. The caller wins on any conflict:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> useAPI</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createUseFetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  baseURL</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">https://api.example.com</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  lazy</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Caller overrides baseURL for this one call</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useAPI</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/status</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  baseURL</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">https://status.example.com</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p><strong>Pass a function</strong> and your options <em>override</em> the caller's. The function receives what the caller passed, so you decide how to merge:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// The base URL is enforced; callers cannot point this client elsewhere</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> useBillingAPI</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createUseFetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">callerOptions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ({</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  ...</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">callerOptions</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  baseURL</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useRuntimeConfig</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">public</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">billingApiUrl</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}));</span></span></code></pre>
</div><p>The function form is also the one to reach for whenever your defaults need something from the Nuxt context, like <code>useRuntimeConfig()</code> or <code>useNuxtApp()</code>. The function runs at the call site, inside setup, where a Nuxt instance exists. A plain object is evaluated once at module scope, where it does not.</p>
<p>A practical way to think about it: object mode for convenience (&quot;most calls want this&quot;), function mode for policy (&quot;every call must have this&quot;).</p>
<h2 id="plugging-in-a-custom-fetch" tabindex="-1">Plugging in a custom <code>$fetch</code> <a class="header-anchor" href="#plugging-in-a-custom-fetch" aria-label="Permalink to &quot;Plugging in a custom `$fetch`&quot;">&ZeroWidthSpace;</a></h2>
<p>If you already have a configured <code>ofetch</code> instance from a plugin, perhaps with retry logic or logging, you can hand it to the factory:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// app/plugins/api.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> defineNuxtPlugin</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> api</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> $fetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">create</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    baseURL</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">https://api.example.com</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    retry</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    retryStatusCodes</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">502</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 503</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 504</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> provide</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> api </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> };</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// app/composables/useAPI.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> useAPI</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createUseFetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">callerOptions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ({</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  $fetch</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> useNuxtApp</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">$api</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> as</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> typeof</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> $fetch</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  ...</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">callerOptions</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}));</span></span></code></pre>
</div><p>Note the function form again: <code>useNuxtApp()</code> has to run in setup, so it cannot sit in a plain object at the top of the file.</p>
<p><code>createUseAsyncData</code> follows the same pattern for cases where the &quot;fetch&quot; is not an HTTP call at all, such as a GraphQL client, an SDK, or a Supabase query, but you still want shared defaults like <code>lazy</code>, <code>deep</code>, or <code>getCachedData</code>.</p>
<h2 id="when-not-to-bother" tabindex="-1">When not to bother <a class="header-anchor" href="#when-not-to-bother" aria-label="Permalink to &quot;When not to bother&quot;">&ZeroWidthSpace;</a></h2>
<p>If your app hits one same-origin <code>/api</code> route and needs no headers, plain <code>useFetch</code> is fine; a factory adds a file and a name for no gain. And remember that <code>useFetch</code>-style composables are for loading data during render. For a form submit or a button click, call <code>$fetch</code> (or your plugin's <code>$api</code>) directly. Wrapping event handlers in <code>useAPI</code> will not give you SSR benefits and can cause confusing caching.</p>
<h2 id="wrapping-up" tabindex="-1">Wrapping up <a class="header-anchor" href="#wrapping-up" aria-label="Permalink to &quot;Wrapping up&quot;">&ZeroWidthSpace;</a></h2>
<p><code>createUseFetch</code> turns a pattern almost every Nuxt team reinvented into one line that keeps full typing and SSR key handling intact. If you are on Nuxt 4.4 or later, look for your existing <code>useFetch</code> wrappers, move them into <code>composables/</code> as factories, and choose object or function mode deliberately. The <a href="https://nuxt.com/docs/4.x/api/composables/create-use-fetch" target="_blank" rel="noreferrer">createUseFetch docs</a> and the <a href="https://nuxt.com/docs/4.x/guide/recipes/custom-usefetch" target="_blank" rel="noreferrer">custom useFetch recipe</a> cover the remaining options.</p>
]]></content:encoded>
            <category>nuxt.js</category>
            <category>vue</category>
            <category>typescript</category>
            <category>web-apis</category>
            <enclosure url="https://danholloran.me/images/posts/nuxt-createusefetch-one-typed-api-client-without-the-wrapper-boilerplate.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Your tsconfig Is Still Written for TypeScript 5. Here's the Cleanup TS 7 Forces]]></title>
            <link>https://danholloran.me/posts/your-tsconfig-is-still-written-for-typescript-5</link>
            <guid isPermaLink="false">https://danholloran.me/posts/your-tsconfig-is-still-written-for-typescript-5</guid>
            <pubDate>Thu, 01 Oct 2026 09:15:17 GMT</pubDate>
            <description><![CDATA[TypeScript 6 flipped half a dozen compiler defaults and TypeScript 7 deleted deprecated options. Here's the tsconfig audit for the native compiler.]]></description>
            <content:encoded><![CDATA[<p>Most <code>tsconfig.json</code> files are archaeology. Somebody copied one from a starter repo in 2021, somebody else added <code>baseUrl</code> so the <code>@/</code> imports would work, and a third person set <code>moduleResolution: &quot;node&quot;</code> because a Stack Overflow answer said so. Nobody has touched it since, because it worked.</p>
<p>That stops being true this year. TypeScript 6.0 (March 2026) was a deliberate bridge release: it changed a batch of defaults and deprecated a list of legacy options. TypeScript 7.0, the Go-native compiler that went GA in July, keeps the new defaults and <strong>removes</strong> the deprecated options outright. If you upgraded to 6.0 and silenced the warnings with <code>&quot;ignoreDeprecations&quot;: &quot;6.0&quot;</code>, you didn't fix anything. You scheduled the breakage for your TS 7 upgrade.</p>
<p>Here's the audit, in the order it will actually bite you.</p>
<h2 id="the-two-changes-with-confusing-error-messages" tabindex="-1">The two changes with confusing error messages <a class="header-anchor" href="#the-two-changes-with-confusing-error-messages" aria-label="Permalink to &quot;The two changes with confusing error messages&quot;">&ZeroWidthSpace;</a></h2>
<p>The TypeScript team called these out up front because the errors they produce don't point at the cause.</p>
<p><strong><code>types</code> now defaults to <code>[]</code>.</strong> Previously, TypeScript loaded every package under <code>node_modules/@types</code> into the global scope. That's how <code>process</code>, <code>describe</code>, and <code>it</code> showed up without an import. In 6.0 and later, nothing is loaded unless you list it. The symptom is a flood of <code>Cannot find name 'process'</code> or <code>Cannot find name 'describe'</code> errors that look like a broken install.</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">compilerOptions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">types</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">node</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">vitest/globals</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>You can restore the old behavior with <code>&quot;types&quot;: [&quot;*&quot;]</code>, but don't. The team reports that many projects saw 20 to 50 percent faster builds just from listing the types they actually use, because a typical monorepo transitively drags in hundreds of <code>@types</code> packages nobody imports.</p>
<p><strong><code>rootDir</code> now defaults to the tsconfig's directory.</strong> It used to be inferred from the common ancestor of your source files. If your sources live in <code>src/</code> and you never set <code>rootDir</code>, your output silently moves from <code>dist/index.js</code> to <code>dist/src/index.js</code>. Nothing errors. Your <code>package.json</code> <code>main</code> field just points at a file that no longer exists.</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">compilerOptions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">rootDir</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./src</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">outDir</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./dist</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">include</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./src</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>If you use a bundler and <code>noEmit</code>, this one won't affect you. If you publish a library with <code>tsc</code>, check it first.</p>
<h2 id="the-defaults-that-flipped-quietly" tabindex="-1">The defaults that flipped quietly <a class="header-anchor" href="#the-defaults-that-flipped-quietly" aria-label="Permalink to &quot;The defaults that flipped quietly&quot;">&ZeroWidthSpace;</a></h2>
<p>These changed in 6.0 and are the baseline in 7.0:</p>
<ul>
<li><code>strict</code> is now <code>true</code>. If you were relying on the old <code>false</code> default, you'll suddenly get every <code>strictNullChecks</code> and <code>noImplicitAny</code> error at once.</li>
<li><code>module</code> defaults to <code>esnext</code>, and <code>target</code> floats to the newest supported spec (currently <code>es2025</code>).</li>
<li><code>noUncheckedSideEffectImports</code> is <code>true</code>, so a typo in <code>import './polyfil'</code> is now an error instead of a silent no-op.</li>
</ul>
<p>The honest move with <code>strict</code> is to set it explicitly either way. If your codebase isn't ready, write <code>&quot;strict&quot;: false</code> so the decision is visible in the file rather than inherited from a default that changed under you. Then turn on the individual checks one at a time.</p>
<h2 id="the-options-that-are-gone-in-7-0" tabindex="-1">The options that are gone in 7.0 <a class="header-anchor" href="#the-options-that-are-gone-in-7-0" aria-label="Permalink to &quot;The options that are gone in 7.0&quot;">&ZeroWidthSpace;</a></h2>
<p>These were deprecated in 6.0 and do not exist in 7.0:</p>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Option</th>
<th>Replace with</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>moduleResolution: &quot;node&quot;</code> / <code>&quot;node10&quot;</code></td>
<td><code>&quot;nodenext&quot;</code> for Node, <code>&quot;bundler&quot;</code> for Vite/webpack/Bun</td>
</tr>
<tr>
<td><code>moduleResolution: &quot;classic&quot;</code></td>
<td><code>&quot;bundler&quot;</code> or <code>&quot;nodenext&quot;</code></td>
</tr>
<tr>
<td><code>baseUrl</code></td>
<td>explicit prefixes in <code>paths</code></td>
</tr>
<tr>
<td><code>target: &quot;es5&quot;</code>, <code>downlevelIteration</code></td>
<td><code>es2015</code> or later; let your bundler handle older targets</td>
</tr>
<tr>
<td><code>module: &quot;amd&quot;</code>, <code>&quot;umd&quot;</code>, <code>&quot;system&quot;</code>, <code>outFile</code></td>
<td>a bundler</td>
</tr>
<tr>
<td><code>esModuleInterop: false</code>, <code>allowSyntheticDefaultImports: false</code></td>
<td>delete them; both are always on</td>
</tr>
</tbody>
</table>
</div>
<p><code>baseUrl</code> is the one most frontend projects hit. Almost everyone used it as a prefix for <code>paths</code>, but it was also a lookup root, which meant TypeScript would happily resolve <code>import x from &quot;utils&quot;</code> to <code>src/utils.ts</code> even though no bundler would. The fix is mechanical:</p>
<div class="language-diff vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">diff</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    "compilerOptions": {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">     "baseUrl": "./src",</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">      "paths": {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">       "@/*": ["*"]</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">+</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">       "@/*": ["./src/*"]</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">      }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  }</span></span></code></pre>
</div><p>One related improvement: 6.0 now allows <code>moduleResolution: &quot;bundler&quot;</code> together with <code>module: &quot;commonjs&quot;</code>, which is the cleanest landing spot for older projects that still emit CommonJS but used <code>node10</code> resolution.</p>
<h2 id="doing-the-migration-without-guessing" tabindex="-1">Doing the migration without guessing <a class="header-anchor" href="#doing-the-migration-without-guessing" aria-label="Permalink to &quot;Doing the migration without guessing&quot;">&ZeroWidthSpace;</a></h2>
<p>Run your current version with the deprecation warnings visible first. Remove <code>ignoreDeprecations</code> if you added it, run <code>tsc --noEmit</code>, and treat every warning as a ticket. For <code>baseUrl</code> and <code>rootDir</code>, the TypeScript team's experimental <a href="https://github.com/andrewbranch/ts5to6" target="_blank" rel="noreferrer"><code>ts5to6</code></a> codemod will rewrite the config across a monorepo for you.</p>
<p>Once 6.0 is clean, 7.0 should be uneventful for the config side. If you still see odd inference differences, 6.0's <code>--stableTypeOrdering</code> flag makes the JS compiler order types the way the Go compiler does, so you can shake those out before switching binaries.</p>
<p>A tsconfig is supposed to describe what your project actually does. After this cleanup, it finally will: explicit <code>types</code>, explicit <code>rootDir</code>, a <code>moduleResolution</code> that matches your real runtime, and no lookup roots that resolve imports your bundler never could. The full list of changes is in the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/" target="_blank" rel="noreferrer">TypeScript 6.0 release notes</a>, and it's worth one careful read before you bump the version in <code>package.json</code>.</p>
]]></content:encoded>
            <category>typescript</category>
            <category>tooling</category>
            <category>bundlers</category>
            <category>node</category>
            <enclosure url="https://danholloran.me/images/posts/your-tsconfig-is-still-written-for-typescript-5.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Node.js Type Stripping: Run TypeScript Without a Build Step]]></title>
            <link>https://danholloran.me/posts/nodejs-type-stripping-run-typescript-without-a-build-step</link>
            <guid isPermaLink="false">https://danholloran.me/posts/nodejs-type-stripping-run-typescript-without-a-build-step</guid>
            <pubDate>Mon, 28 Sep 2026 09:15:18 GMT</pubDate>
            <description><![CDATA[Node.js now runs .ts files by erasing types, not compiling them. Here's what that buys you, what it refuses to do, and the tsconfig that keeps you honest.]]></description>
            <content:encoded><![CDATA[<p>Every Node project written in TypeScript used to carry the same small tax. You wanted to run a one-off script, so you reached for <code>ts-node</code>, then fought its ESM loader, then switched to <code>tsx</code>, then added a <code>build</code> step so production wouldn't depend on either. None of that was hard. It was just friction, repeated in every repo.</p>
<p>That tax is mostly gone. Type stripping has been on by default since Node 23.6 and 22.18, and as of Node 25.2 and 24.12 the docs mark it <strong>Stable</strong>. You can type <code>node script.ts</code> and it runs. The catch is that &quot;Node runs TypeScript&quot; is a slightly misleading headline. Node doesn't compile TypeScript. It erases it, and knowing the difference is what keeps you from hitting confusing runtime errors.</p>
<h2 id="erasing-not-compiling" tabindex="-1">Erasing, not compiling <a class="header-anchor" href="#erasing-not-compiling" aria-label="Permalink to &quot;Erasing, not compiling&quot;">&ZeroWidthSpace;</a></h2>
<p>When Node loads a <code>.ts</code>, <code>.mts</code>, or <code>.cts</code> file, it hands the source to a small SWC-based module called amaro. Amaro finds the type annotations, interfaces, and type aliases and replaces them with whitespace. What's left is plain JavaScript with the exact same line and column positions, so stack traces point at the right place without source maps.</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// greet.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">interface</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> User</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  name</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  visits</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> number</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> greet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">user</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> User</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> `</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Welcome back, </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">user</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">name</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> (</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">user</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">visits</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> visits)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">node</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> greet.ts</span></span></code></pre>
</div><p>Two things this does <strong>not</strong> do. It doesn't type check: a file full of type errors runs fine as long as the JavaScript underneath is valid. And it doesn't read <code>tsconfig.json</code> at all, so <code>paths</code>, <code>target</code> downleveling, and every other compiler setting are ignored. Your editor and <code>tsc --noEmit</code> in CI are still where type safety lives. Node just stopped making you transpile before you can execute.</p>
<h2 id="the-syntax-that-breaks" tabindex="-1">The syntax that breaks <a class="header-anchor" href="#the-syntax-that-breaks" aria-label="Permalink to &quot;The syntax that breaks&quot;">&ZeroWidthSpace;</a></h2>
<p>Because Node only deletes code, anything in TypeScript that <em>generates</em> JavaScript can't work. The big four are <code>enum</code> declarations, <code>namespace</code> blocks with runtime values, constructor parameter properties, and import aliases like <code>import Alias = Some.Namespace</code>. Hit one and Node throws <code>ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX</code>.</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Fails: parameter properties generate assignments</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">class</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Repo</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  constructor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">private</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> db</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Database</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Works: the same thing, written as erasable syntax</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">class</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Repo</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  private</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> db</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Database</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  constructor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">db</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Database</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    this</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">db</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> db</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Enums are the one most teams trip over. A <code>const</code> object with <code>as const</code> gives you the same autocomplete and a real runtime value:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> Status</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  Draft</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">draft</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  Published</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">published</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> as</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> type</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Status</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">typeof</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Status</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)[</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">keyof</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> typeof</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Status</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">];</span></span></code></pre>
</div><p>There used to be an escape hatch, <code>--experimental-transform-types</code>, that handled enums and friends. Node 26 removed it. If you need full TypeScript semantics at runtime, the docs now point you to a third-party loader like <code>tsx</code>. For everything else, write erasable TypeScript.</p>
<p>Decorators and <code>.tsx</code> files are also out. Decorators wait on native JavaScript support, and JSX needs a real transform.</p>
<h2 id="let-the-compiler-enforce-it-for-you" tabindex="-1">Let the compiler enforce it for you <a class="header-anchor" href="#let-the-compiler-enforce-it-for-you" aria-label="Permalink to &quot;Let the compiler enforce it for you&quot;">&ZeroWidthSpace;</a></h2>
<p>You don't have to memorize that list. TypeScript 5.8 added <code>erasableSyntaxOnly</code>, which turns every non-erasable construct into a compile error. The Node docs recommend this setup:</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">compilerOptions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">noEmit</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">target</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">esnext</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">module</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">nodenext</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">rewriteRelativeImportExtensions</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">erasableSyntaxOnly</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">verbatimModuleSyntax</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p><code>verbatimModuleSyntax</code> matters more than it looks. Node can only strip a type import it can recognize, so you must write <code>import type { User } from './user.ts'</code> or <code>import { greet, type User } from './greet.ts'</code>. A plain <code>import { User }</code> is treated as a value import and fails at runtime because nothing named <code>User</code> exists in the emitted JavaScript. This flag makes <code>tsc</code> catch that before Node does.</p>
<p>Two more habits follow from &quot;Node treats <code>.ts</code> like <code>.js</code>.&quot; Import specifiers need the real extension (<code>./db.ts</code>, not <code>./db</code>), and module format follows the nearest <code>package.json</code> <code>&quot;type&quot;</code> field, just like JavaScript. <code>rewriteRelativeImportExtensions</code> lets you keep writing <code>.ts</code> specifiers and still emit working <code>.js</code> if you later publish compiled output.</p>
<p>If you relied on <code>tsconfig</code> <code>paths</code> for aliases like <code>@/lib/db</code>, switch to Node's own subpath imports. They're defined in <code>package.json</code> and must start with <code>#</code>:</p>
<div class="language-json vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">json</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">module</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">imports</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">#lib/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./src/lib/*</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> connect </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">#lib/db.ts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span></code></pre>
</div><h2 id="where-it-fits-and-where-it-doesn-t" tabindex="-1">Where it fits, and where it doesn't <a class="header-anchor" href="#where-it-fits-and-where-it-doesn-t" aria-label="Permalink to &quot;Where it fits, and where it doesn't&quot;">&ZeroWidthSpace;</a></h2>
<p>Type stripping shines for scripts, CLIs, internal services, and test files. Pair it with the built-in test runner and a small project can drop <code>ts-node</code>, <code>tsx</code>, and a build step entirely.</p>
<p>It's not for publishing libraries. Node deliberately refuses to strip types from anything under <code>node_modules</code>, so packages still need to ship compiled JavaScript and <code>.d.ts</code> files. And if your codebase leans on enums, decorators, or JSX on the server, a full transpiler remains the right tool.</p>
<p>The practical move this week: add <code>erasableSyntaxOnly</code> to an existing project and see what lights up. If the list is short, you're a few small refactors away from deleting a dependency and a build step. The <a href="https://nodejs.org/api/typescript.html" target="_blank" rel="noreferrer">Node.js TypeScript docs</a> cover the remaining edge cases.</p>
]]></content:encoded>
            <category>node.js</category>
            <category>node</category>
            <category>typescript</category>
            <category>tooling</category>
            <enclosure url="https://danholloran.me/images/posts/nodejs-type-stripping-run-typescript-without-a-build-step.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Compression Dictionaries: Ship Only the Diff When Your Bundle Changes]]></title>
            <link>https://danholloran.me/posts/compression-dictionaries-ship-only-the-diff-when-your-bundle-changes</link>
            <guid isPermaLink="false">https://danholloran.me/posts/compression-dictionaries-ship-only-the-diff-when-your-bundle-changes</guid>
            <pubDate>Sun, 27 Sep 2026 09:15:39 GMT</pubDate>
            <description><![CDATA[Compression Dictionary Transport (RFC 9842) lets the browser tell your server which file it has cached, so a redeployed bundle can arrive as a tiny delta.]]></description>
            <content:encoded><![CDATA[<p>You ship a one-line bug fix. Your bundler dutifully produces <code>app.8f3c1a.js</code>, a brand new filename, and every returning visitor downloads the whole 270 KB file again. The browser already has 99% of those bytes sitting in its cache under the old hash, but HTTP compression has no way to say &quot;you already know most of this.&quot; Gzip and Brotli compress each response in isolation, so a tiny code change costs a full re-download.</p>
<p>Compression Dictionary Transport fixes exactly that. It became a real standard as <a href="https://datatracker.ietf.org/doc/rfc9842/" target="_blank" rel="noreferrer">RFC 9842</a> in September 2025, it ships in Chrome and Edge 130+, and Cloudflare opened a beta for it in April 2026. The idea is simple: let the browser tell the server which previous response it has cached, and let the server compress the new response against it. For versioned JavaScript, the result is often a delta measured in single-digit kilobytes.</p>
<h2 id="how-the-handshake-works" tabindex="-1">How the handshake works <a class="header-anchor" href="#how-the-handshake-works" aria-label="Permalink to &quot;How the handshake works&quot;">&ZeroWidthSpace;</a></h2>
<p>There are three moving parts: a response that volunteers to be a dictionary, a request that advertises which dictionary is available, and a response compressed with it.</p>
<p>First, the server marks a resource as reusable with the <code>Use-As-Dictionary</code> header. The <code>match</code> value is a URL pattern describing which future requests this file can help with:</p>
<div class="language-http vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">http</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">HTTP</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">/</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1.1</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 200</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> OK</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Content-Type</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> text/javascript</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Cache-Control</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> public, max-age=31536000</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Use-As-Dictionary</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> match="/js/app.*.js", id="app-v1"</span></span></code></pre>
</div><p>The browser stores that response as a dictionary. The next time it requests something matching <code>/js/app.*.js</code>, say after you deploy <code>app.2d9e44.js</code>, it adds two things to the request: new encodings in <code>Accept-Encoding</code>, and the SHA-256 hash of the dictionary it holds.</p>
<div class="language-http vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">http</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">GET</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> /js/app.2d9e44.js </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">HTTP</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">/</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1.1</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Accept-Encoding</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> gzip, br, zstd, dcb, dcz</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Available-Dictionary</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> :pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Dictionary-ID</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> "app-v1"</span></span></code></pre>
</div><p><code>dcb</code> is dictionary-compressed Brotli and <code>dcz</code> is dictionary-compressed Zstandard. If the server has a delta prepared for that exact hash, it responds with one of them:</p>
<div class="language-http vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">http</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">HTTP</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">/</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1.1</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 200</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> OK</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Content-Encoding</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> dcz</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Vary</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> accept-encoding, available-dictionary</span></span></code></pre>
</div><p>The <code>Vary</code> header is not optional. Without it, a CDN could happily serve a dictionary-compressed body to a client that never had the dictionary, and that client gets garbage. Also note that <code>Dictionary-ID</code> is a convenience label; the server must still verify the <code>Available-Dictionary</code> hash before trusting that the client has the bytes it thinks it has.</p>
<h2 id="building-the-deltas-at-deploy-time" tabindex="-1">Building the deltas at deploy time <a class="header-anchor" href="#building-the-deltas-at-deploy-time" aria-label="Permalink to &quot;Building the deltas at deploy time&quot;">&ZeroWidthSpace;</a></h2>
<p>The practical pattern for static assets is to precompress at build time. For every new bundle, compress it against the previous release's version of the same file and store the result next to it. The Zstandard and Brotli CLIs both accept a dictionary:</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"># previous release is the dictionary, new build is the payload</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">zstd</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> -19</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> --patch-from=dist-prev/app.8f3c1a.js</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> dist/app.2d9e44.js</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> -o</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> dist/app.2d9e44.js.dcz-raw</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"># the hash the browser will send in Available-Dictionary</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">openssl</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> dgst</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> -sha256</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> -binary</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> dist-prev/app.8f3c1a.js</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> |</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> base64</span></span></code></pre>
</div><p>The <code>dcz</code> and <code>dcb</code> formats wrap the compressed stream with a short header that embeds the dictionary hash, so the browser can confirm it decoded against the right file. Tooling like Cloudflare's implementation handles that framing for you; if you roll your own, follow the RFC's format section rather than guessing.</p>
<p>On the server, the logic is a lookup: does a delta exist for this path and this dictionary hash? If yes, serve it. If no, fall back to normal Brotli.</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> createReadStream</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> existsSync </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">node:fs</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">app</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/js/:file</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">req</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> res</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> next</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> hash</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> req</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">available-dictionary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)?.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">slice</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> -</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // strip the colons</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> accepts</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> req</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">accept-encoding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ??</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ""</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> delta</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> hash </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">&#x26;&#x26;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> deltaIndex</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">get</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">req</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">params</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">file</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">hash</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">vary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Accept-Encoding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">).</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">vary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Available-Dictionary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Use-As-Dictionary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> '</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">match="/js/app.*.js", id="app"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">delta </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">&#x26;&#x26;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> accepts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">includes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">dcz</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> &#x26;&#x26;</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> existsSync</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">delta</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">))</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">set</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Content-Encoding</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">dcz</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">Content-Type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">text/javascript</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createReadStream</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">delta</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">).</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">pipe</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">res</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  next</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // regular static handler serves br/gzip</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>Notice the new file also sends <code>Use-As-Dictionary</code>. Each release becomes the dictionary for the next one, so a returning visitor keeps getting deltas across deploys instead of just one.</p>
<p>How big is the win? In Cloudflare's lab test, a 272 KB JavaScript asset was 92.2 KB with gzip. Compressed against its previous version with <code>dcz</code>, it came out at 2.6 KB. Your numbers depend entirely on how much actually changed between builds, but for frequent small deploys that ratio is the whole point.</p>
<h2 id="shared-dictionaries-for-first-visits" tabindex="-1">Shared dictionaries for first visits <a class="header-anchor" href="#shared-dictionaries-for-first-visits" aria-label="Permalink to &quot;Shared dictionaries for first visits&quot;">&ZeroWidthSpace;</a></h2>
<p>Deltas only help returning visitors. For first loads, you can ship a separate dictionary built from content common across your pages, like your HTML boilerplate or shared component markup, and hint it to the browser at idle priority:</p>
<div class="language-html vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">html</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">link</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> rel=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">compression-dictionary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> href=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/dictionary.dat</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span></code></pre>
</div><p>Or with a response header: <code>Link: &lt;/dictionary.dat&gt;; rel=&quot;compression-dictionary&quot;</code>. The browser downloads it in the background, and later page navigations can be compressed against it.</p>
<h2 id="the-fine-print" tabindex="-1">The fine print <a class="header-anchor" href="#the-fine-print" aria-label="Permalink to &quot;The fine print&quot;">&ZeroWidthSpace;</a></h2>
<p>A few rules will bite you if you skip them. Dictionaries must be same-origin with the resources they compress, and dictionary-compressed responses must be same-origin with the document or served with proper CORS. If you use <code>&lt;link rel=&quot;compression-dictionary&quot;&gt;</code> with a Content Security Policy, <code>connect-src</code> has to allow the dictionary URL. Only secure contexts participate.</p>
<p>Browser support is the bigger caveat. Chromium browsers support it today, Firefox has an active implementation bug, and Safari has not announced support. That is fine, because the design degrades gracefully: browsers that don't send <code>dcb</code> or <code>dcz</code> in <code>Accept-Encoding</code> never see a dictionary response and just get your normal Brotli. You are adding a fast path, not a dependency.</p>
<p>If you deploy often and your users come back often, this is one of the rare performance wins that gets bigger the more you ship. Start with your largest versioned JavaScript bundle, precompress one delta per release, and measure transfer size in DevTools on a repeat visit. The <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Compression_dictionary_transport" target="_blank" rel="noreferrer">MDN guide to compression dictionary transport</a> and the <a href="https://developer.chrome.com/blog/shared-dictionary-compression" target="_blank" rel="noreferrer">Chrome team's write-up</a> cover the remaining details.</p>
]]></content:encoded>
            <category>web-performance</category>
            <category>performance</category>
            <category>web-apis</category>
            <category>tooling</category>
            <enclosure url="https://danholloran.me/images/posts/compression-dictionaries-ship-only-the-diff-when-your-bundle-changes.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Uint8Array toBase64 and toHex: Stop Round-Tripping Bytes Through btoa]]></title>
            <link>https://danholloran.me/posts/uint8array-tobase64-and-tohex-stop-round-tripping-bytes-through-btoa</link>
            <guid isPermaLink="false">https://danholloran.me/posts/uint8array-tobase64-and-tohex-stop-round-tripping-bytes-through-btoa</guid>
            <pubDate>Thu, 24 Sep 2026 09:14:48 GMT</pubDate>
            <description><![CDATA[JavaScript can finally turn bytes into base64 and hex without the String.fromCharCode and btoa dance. Here's how Uint8Array's new encoding methods work.]]></description>
            <content:encoded><![CDATA[<p>Every codebase that touches binary data has the same little helper buried in a <code>utils</code> folder. It takes a <code>Uint8Array</code>, spreads it into <code>String.fromCharCode</code>, hands the result to <code>btoa</code>, and hopes for the best. Its sibling goes the other way with <code>atob</code> and a <code>charCodeAt</code> loop. And somewhere nearby there's a third helper that maps each byte to <code>toString(16).padStart(2, '0')</code> so you can print a hash.</p>
<p>None of those helpers are wrong, exactly. They're just working around the fact that <code>btoa</code> and <code>atob</code> were designed for strings, not bytes. That gap is now closed: <code>Uint8Array</code> has native methods for base64 and hex, in both directions, and they've been Baseline across current Chrome, Firefox, and Safari since September 2025.</p>
<h2 id="the-new-methods-at-a-glance" tabindex="-1">The new methods at a glance <a class="header-anchor" href="#the-new-methods-at-a-glance" aria-label="Permalink to &quot;The new methods at a glance&quot;">&ZeroWidthSpace;</a></h2>
<p>There are six of them, split into three pairs:</p>
<ul>
<li><code>bytes.toBase64(options)</code> and <code>Uint8Array.fromBase64(string, options)</code></li>
<li><code>bytes.toHex()</code> and <code>Uint8Array.fromHex(string)</code></li>
<li><code>target.setFromBase64(string, options)</code> and <code>target.setFromHex(string)</code> for writing into an existing buffer</li>
</ul>
<p>Here's the old helper next to its replacement:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Before: works, but only by pretending bytes are Latin-1 characters</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> bytesToBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> btoa</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">String</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">fromCharCode</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">...</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> base64ToBytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">atob</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">c</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> c</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">charCodeAt</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// After</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> b64</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> decoded</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">fromBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span></code></pre>
</div><p>The &quot;after&quot; version isn't just shorter. The spread in <code>String.fromCharCode(...bytes)</code> passes every byte as a separate function argument, so a large file can blow past the engine's argument limit and throw a <code>RangeError</code>. The native methods don't have that ceiling, and they skip the intermediate &quot;binary string&quot; entirely.</p>
<p>Hex gets the same treatment, which makes Web Crypto output a one-liner:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">async</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> sha256Hex</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> data</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> TextEncoder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">encode</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> digest</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> crypto</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">subtle</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">digest</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">SHA-256</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> data</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">digest</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">).</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toHex</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> sha256Hex</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">hello</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'</span></span></code></pre>
</div><p><code>Uint8Array.fromHex()</code> goes the other direction and throws a <code>SyntaxError</code> on odd-length strings or non-hex characters, so you get validation for free instead of silently producing <code>NaN</code> bytes.</p>
<h2 id="base64url-and-padding-without-string-surgery" tabindex="-1">base64url and padding without string surgery <a class="header-anchor" href="#base64url-and-padding-without-string-surgery" aria-label="Permalink to &quot;base64url and padding without string surgery&quot;">&ZeroWidthSpace;</a></h2>
<p>Standard base64 uses <code>+</code> and <code>/</code>, which break in URLs and filenames. The usual fix is a chain of <code>.replace()</code> calls to swap in <code>-</code> and <code>_</code> and strip the trailing <code>=</code>. Now it's an option:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> crypto</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">getRandomValues</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">16</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> token</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> alphabet</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">base64url</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> omitPadding</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// e.g. 'q3N8y0Zp1VbX-7k2_JmR4g'</span></span></code></pre>
</div><p>Decoding takes the same <code>alphabet</code> option. That makes peeking inside a JWT payload pleasantly boring, since JWT segments are base64url with the padding removed:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> readJwtPayload</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">jwt</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> payload</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> jwt</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">split</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">.</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> bytes</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">fromBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">payload</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> alphabet</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">base64url</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> JSON</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">parse</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> TextDecoder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">().</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">decode</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>That works because the default <code>lastChunkHandling</code> mode, <code>&quot;loose&quot;</code>, accepts a final chunk of two or three characters without padding. If you're validating input you don't control and want to reject anything non-canonical, pass <code>lastChunkHandling: 'strict'</code>: the last chunk must then be padded to four characters, and any leftover &quot;overflow bits&quot; must be zero. Decoding also ignores ASCII whitespace, so base64 that was wrapped across lines in a PEM file or an email decodes without a cleanup pass.</p>
<p>One more quiet win: because you're decoding to bytes, not to a string, there's no more <code>InvalidCharacterError</code> from feeding <code>btoa</code> a string with an emoji in it. Encode text with <code>TextEncoder</code> first, then call <code>toBase64()</code>, and UTF-8 just works.</p>
<h2 id="decoding-into-memory-you-already-have" tabindex="-1">Decoding into memory you already have <a class="header-anchor" href="#decoding-into-memory-you-already-have" aria-label="Permalink to &quot;Decoding into memory you already have&quot;">&ZeroWidthSpace;</a></h2>
<p>The <code>setFrom*</code> methods write into an existing <code>Uint8Array</code> and return <code>{ read, written }</code> so you know how much input was consumed and how many bytes landed. That's handy when you're reusing a buffer or decoding base64 that arrives in pieces:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> buffer</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1024</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> offset </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> pending </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ""</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> onChunk</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  pending </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">+=</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> read</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> written</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> buffer</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">subarray</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">offset</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">setFromBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">pending</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> lastChunkHandling</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">stop-before-partial</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  offset </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">+=</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> written</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  pending </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> pending</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">slice</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">read</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>With <code>&quot;stop-before-partial&quot;</code>, an incomplete trailing chunk is left alone instead of being decoded early or throwing, so the next network chunk can finish it. The <code>read</code> count tells you exactly where to resume.</p>
<h2 id="when-to-reach-for-them-and-when-not-yet" tabindex="-1">When to reach for them (and when not yet) <a class="header-anchor" href="#when-to-reach-for-them-and-when-not-yet" aria-label="Permalink to &quot;When to reach for them (and when not yet)&quot;">&ZeroWidthSpace;</a></h2>
<p>If your app targets current evergreen browsers, you can use these today and delete the helpers. On the server, check your runtime version before you rip out <code>Buffer.from(x, 'base64')</code>. Node's <code>Buffer</code> still works fine, but these methods give you one API that runs the same in the browser, Deno, Bun, and edge runtimes.</p>
<p>For older targets, feature-detect and fall back:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> toBase64</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  typeof</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Uint8Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">prototype</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">toBase64</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ===</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">function</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    ?</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">toBase64</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    :</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">bytes</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">        btoa</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">bytes</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> String</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">fromCharCode</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)).</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">join</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">""</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span></code></pre>
</div><p>Or pull in a polyfill: <code>core-js</code> covers the full set, and the <code>es-arraybuffer-base64</code> shim does too.</p>
<p>The takeaway is small but satisfying: bytes can finally stay bytes. The next time you reach for <code>btoa</code>, check whether what you actually have is a <code>Uint8Array</code>. If it is, <code>toBase64()</code> is almost certainly the better call. The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64" target="_blank" rel="noreferrer">MDN reference for <code>Uint8Array.fromBase64()</code></a> covers every option in detail, including the edge cases around padding and overflow bits.</p>
]]></content:encoded>
            <category>javascript</category>
            <category>web-apis</category>
            <category>node</category>
            <category>performance</category>
            <enclosure url="https://danholloran.me/images/posts/uint8array-tobase64-and-tohex-stop-round-tripping-bytes-through-btoa.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[import defer: Lazy Modules Without Going Async]]></title>
            <link>https://danholloran.me/posts/import-defer-lazy-modules-without-going-async</link>
            <guid isPermaLink="false">https://danholloran.me/posts/import-defer-lazy-modules-without-going-async</guid>
            <pubDate>Mon, 21 Sep 2026 09:09:50 GMT</pubDate>
            <description><![CDATA[ES2026's import defer evaluates a module the first time you touch its namespace, not at startup, so heavy work leaves the cold path without going async.]]></description>
            <content:encoded><![CDATA[<p>Every codebase has that one import. A syntax highlighter, a PDF renderer, a date-formatting library with a locale table the size of a small novel. It sits at the top of a file, it gets evaluated the moment the module graph runs, and it is needed on exactly one code path that most users never hit.</p>
<p>The usual fix is <code>await import()</code>. It works, but it charges rent: the function that needed the module becomes async, and so does its caller, and its caller's caller. You wanted to move some work later in time, and instead you rewrote a call stack. <code>import defer</code> is the piece that was missing, and it is riding along with ES2026.</p>
<h2 id="linked-up-front-evaluated-on-first-touch" tabindex="-1">Linked up front, evaluated on first touch <a class="header-anchor" href="#linked-up-front-evaluated-on-first-touch" aria-label="Permalink to &quot;Linked up front, evaluated on first touch&quot;">&ZeroWidthSpace;</a></h2>
<p>The syntax is a single keyword in one position:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> defer </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">*</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> as</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ts </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">typescript</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> compileFile</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">path</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">  // The typescript module graph evaluates here, on first property access.</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">createProgram</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">([</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">path</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {});</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Nothing from <code>typescript</code> runs at startup. The module and its dependencies are still resolved, fetched, parsed, and linked, so a missing file or a bad named import still blows up immediately rather than lurking. What is deferred is evaluation: the top-level code only runs the first time you read a property off <code>ts</code>. If <code>compileFile</code> is never called, that graph never executes.</p>
<p>That distinction is the whole value proposition. <code>import()</code> defers everything, which is why it has to be async. <code>import defer</code> defers only the synchronous part that can safely be moved, so property access stays synchronous and nothing above it needs to change colour.</p>
<p>Only the namespace form works. There is no <code>import defer { createProgram } from &quot;typescript&quot;</code> and no default form, because the namespace object is the thing doing the work — evaluation is triggered by a property read, and a destructured binding has nothing left to intercept.</p>
<h2 id="the-parts-that-will-surprise-you" tabindex="-1">The parts that will surprise you <a class="header-anchor" href="#the-parts-that-will-surprise-you" aria-label="Permalink to &quot;The parts that will surprise you&quot;">&ZeroWidthSpace;</a></h2>
<p>The deferred namespace is effectively a proxy, and the list of operations that trip evaluation is broader than &quot;read a property&quot;. Reading a key triggers it, but so does <code>&quot;value&quot; in ns</code>, <code>Object.keys(ns)</code>, a <code>for...in</code>, <code>Object.getOwnPropertyDescriptor</code>, and even <code>delete ns.value</code> or <code>Object.defineProperty</code> on it — the operation fails, since the namespace is sealed, but evaluation happens anyway. <code>Object.isSealed()</code> and <code>Object.isFrozen()</code> enumerate keys, so those count too.</p>
<p>Which also means this defeats the whole thing:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> defer </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">*</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> as</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> squares </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./squares.js</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> getSquare</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> squares</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // evaluates immediately — you read a property</span></span></code></pre>
</div><p>Three behaviours are worth committing to memory:</p>
<p><strong>Top-level <code>await</code> opts out.</strong> Reading a property is synchronous, so it cannot wait on an async module. A module containing top-level <code>await</code> is evaluated eagerly, along with whatever its evaluation requires. The rest of the graph can still stay deferred, but do not assume <code>defer</code> bought you anything if the target awaits at the top level.</p>
<p><strong>Side effects move.</strong> If a module installs a polyfill, registers a custom element, or patches a global, deferring it means that side effect now happens at an unpredictable moment, or never. Those modules should stay on a plain <code>import</code>.</p>
<p><strong>Evaluation errors are cached and synchronous.</strong> If the deferred module throws while initializing, the throw comes out of whatever expression touched the namespace, catchable with a normal <code>try...catch</code>. Every later access rethrows the same error rather than retrying.</p>
<p>One more detail that saves a debugging session: the namespace deliberately does not expose an export named <code>then</code>, even after evaluation. Without that carve-out, handing the namespace to <code>Promise.resolve()</code> would look like a thenable and force evaluation as a side effect of promise resolution.</p>
<h2 id="where-you-can-actually-use-it" tabindex="-1">Where you can actually use it <a class="header-anchor" href="#where-you-can-actually-use-it" aria-label="Permalink to &quot;Where you can actually use it&quot;">&ZeroWidthSpace;</a></h2>
<p>Tooling got there first, as usual. TypeScript has understood the syntax since 5.9, and Babel, webpack, and Prettier all handle it. Bun and Deno ship it enabled by default. In browsers, V8 has had it behind a flag with an Intent to Ship filed in September 2026, WebKit has it implemented, and Gecko has signalled support — so treat it as landing rather than landed, and check the compatibility table before you rely on it in production.</p>
<p>The honest use case today is server-side and CLI code, where Bun, Deno, and a bundler give you a straight path. Go find the heaviest module on your cold-start path that is only needed conditionally, swap the import, and measure. If it turns out the module has top-level <code>await</code>, or installs something on import, you have learned something useful about your dependency either way.</p>
]]></content:encoded>
            <category>javascript</category>
            <category>performance</category>
            <category>tooling</category>
            <category>node</category>
            <enclosure url="https://danholloran.me/images/posts/import-defer-lazy-modules-without-going-async.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Turbopack's Persistent Build Cache Only Helps If Your CI Keeps It]]></title>
            <link>https://danholloran.me/posts/turbopacks-persistent-build-cache-only-helps-if-your-ci-keeps-it</link>
            <guid isPermaLink="false">https://danholloran.me/posts/turbopacks-persistent-build-cache-only-helps-if-your-ci-keeps-it</guid>
            <pubDate>Sun, 20 Sep 2026 09:08:32 GMT</pubDate>
            <description><![CDATA[Next.js 16.3 turned on Turbopack's persistent build cache with claimed speedups up to 5.5x. Most CI pipelines see none of it, because containers start empty.]]></description>
            <content:encoded><![CDATA[<p>You upgrade to Next.js 16.3, read that Turbopack's persistent file system cache is now on by default for <code>next build</code>, see a chart claiming up to 5.5x faster builds, push to CI, and watch your pipeline take exactly as long as it did yesterday. Nothing is broken. The feature is doing precisely what it says. The problem is that &quot;persistent&quot; means persisted to a directory, and your build container throws that directory away the moment it exits.</p>
<p>This is the least glamorous kind of performance work: the speedup is real, it's free, and collecting it is an infrastructure chore rather than a code change.</p>
<h2 id="the-cache-is-a-directory-not-a-feature-flag" tabindex="-1">The cache is a directory, not a feature flag <a class="header-anchor" href="#the-cache-is-a-directory-not-a-feature-flag" aria-label="Permalink to &quot;The cache is a directory, not a feature flag&quot;">&ZeroWidthSpace;</a></h2>
<p>Turbopack has been persisting its incremental compilation cache to disk for <code>next dev</code> since 16.1. In 16.3 the same mechanism graduated for <code>next build</code>, and both are enabled by default:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// next.config.ts</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> NextConfig </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">next</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> nextConfig</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> NextConfig</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    turbopackFileSystemCacheForDev</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // default</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    turbopackFileSystemCacheForBuild</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // default</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">};</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> nextConfig</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span></code></pre>
</div><p>Dev writes to <code>.next/dev/cache/turbopack</code>, builds write to <code>.next/cache/turbopack</code>. On the second build Turbopack reads those entries off disk before compiling anything new, so you only pay for what changed. Vercel's published numbers span a wide range depending on how much of the route graph the change touches: <code>nextjs.org</code> went from 21s cold to 9.2s warm, <code>vercel.com/home</code> from 66s to 46s, and <code>vercel.com/geist</code> from 30s to 5.5s.</p>
<p>Every one of those numbers assumes <code>.next/cache</code> survives between runs. Restoring it in GitHub Actions is a handful of lines:</p>
<div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">-</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> uses</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> actions/cache@v4</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">  with</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">    path</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> |</span></span>
<span class="line"><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">      ~/.npm</span></span>
<span class="line"><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">      ${{ github.workspace }}/.next/cache</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">    key</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.[jt]s', '**/*.[jt]sx') }}</span></span>
<span class="line"><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">    restore-keys</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> |</span></span>
<span class="line"><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">      ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-</span></span></code></pre>
</div><p>The <code>restore-keys</code> fallback matters more than the exact key. If your lockfile is unchanged but source files moved, you still want the previous cache as a starting point instead of a cold compile.</p>
<p>Docker is the case that quietly eats this. A containerized build starts from a clean layer, so unless you explicitly mount a cache or use <code>RUN --mount=type=cache</code>, <code>.next/cache</code> is empty on every single build and Turbopack spends time writing a cache that nothing will ever read. If that describes your setup and you don't plan to fix it, turn the thing off rather than paying for it:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  turbopackFileSystemCacheForBuild</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> false</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>That's the actual decision in front of you: persist the directory, or opt out. Leaving it on with nothing to restore is the one configuration that costs you something and returns nothing.</p>
<h2 id="the-dev-memory-win-needs-no-setup-at-all" tabindex="-1">The dev-memory win needs no setup at all <a class="header-anchor" href="#the-dev-memory-win-needs-no-setup-at-all" aria-label="Permalink to &quot;The dev-memory win needs no setup at all&quot;">&ZeroWidthSpace;</a></h2>
<p>The other half of the release is the part you'll notice without touching any config. Because cached results are now safely on disk, Turbopack can evict them from memory instead of holding every visited route forever. After compiling 50 routes, Vercel measured its own dashboard app dropping from 21.5 GB to 2 GB, and <code>nextjs.org</code> from 4,600 MB to 840 MB.</p>
<p>That's roughly a 90% and 82% reduction, and it lands in a moment when your dev machine is more crowded than it used to be. A coding agent, a TypeScript server, a linter, and a test watcher are all competing for the same RAM as your bundler. Eviction requires the dev file system cache to be enabled, and both are on by default. The escape hatch exists if you're debugging cache behavior itself:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  turbopackMemoryEviction</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> false</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic"> // default is 'auto'</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Your mileage genuinely varies here. The reduction depends on the size of your route graph, how much of it you touched, and how long the session ran. A three-route side project has nothing to evict.</p>
<h2 id="two-smaller-things-worth-a-look" tabindex="-1">Two smaller things worth a look <a class="header-anchor" href="#two-smaller-things-worth-a-look" aria-label="Permalink to &quot;Two smaller things worth a look&quot;">&ZeroWidthSpace;</a></h2>
<p>The React Compiler has been stable in Next.js since 16.0, but only as a Babel transform, which meant large apps waited on JS execution during builds. The React team shipped a native Rust port, and Turbopack now wires it up behind an experimental flag with early tests on large apps showing 20-50% compilation wins:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> nextConfig</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  reactCompiler</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    turbopackRustReactCompiler</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">};</span></span></code></pre>
</div><p>Turbopack also picked up Vite's <code>import.meta.glob</code>, which is a small quality-of-life upgrade for anyone hand-maintaining a list of content files:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> posts</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">meta</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">glob</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./posts/*.mdx</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> path</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> in</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> post</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> posts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">[</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">path</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Pass <code>eager: true</code> to import each match immediately. It's Turbopack-only, so it won't work if you're still building with <code>--webpack</code>.</p>
<p>Of the four, the build cache is the one with a real chance of being invisible to you. Before you file the 16.3 upgrade as done, go look at whether <code>.next/cache</code> actually survives a CI run. If it doesn't, you've been paying for a cache you never read.</p>
<p>Sources: the <a href="https://nextjs.org/blog/next-16-3-turbopack" target="_blank" rel="noreferrer">Turbopack 16.3 release post</a>, the <a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopackFileSystemCache" target="_blank" rel="noreferrer"><code>turbopackFileSystemCache</code> reference</a>, and the <a href="https://nextjs.org/docs/app/guides/ci-build-caching" target="_blank" rel="noreferrer">CI build caching guide</a>.</p>
]]></content:encoded>
            <category>next.js</category>
            <category>tooling</category>
            <category>performance</category>
            <category>bundlers</category>
            <enclosure url="https://danholloran.me/images/posts/turbopacks-persistent-build-cache-only-helps-if-your-ci-keeps-it.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[CSS reading-flow: Fix the Tab Order Your Layout Broke]]></title>
            <link>https://danholloran.me/posts/css-reading-flow-fix-the-tab-order-your-layout-broke</link>
            <guid isPermaLink="false">https://danholloran.me/posts/css-reading-flow-fix-the-tab-order-your-layout-broke</guid>
            <pubDate>Thu, 17 Sep 2026 09:08:05 GMT</pubDate>
            <description><![CDATA[Grid and flexbox let you rearrange a layout without touching the DOM, which quietly breaks keyboard navigation. The reading-flow property fixes the order.]]></description>
            <content:encoded><![CDATA[<p>Put a card grid on a page, give one card <code>grid-row: 1 / 3</code> so it sits up top as the hero, and the layout looks exactly right. Then press Tab. Focus lands somewhere in the middle of the page, jumps back up to the hero, skips sideways, and generally behaves like it is reading a different document than the one on screen. It is, in a way: the browser follows DOM order, and you just spent an afternoon making the visual order disagree with it.</p>
<p>This has been the standing tax on grid and flexbox since they shipped. <code>order</code>, <code>row-reverse</code>, <code>grid-area</code>, and auto-placement all move boxes around without moving a single node, and sequential focus navigation never got the memo. The usual workaround was positive <code>tabindex</code>, which trades one bug for a worse one. <code>reading-flow</code> and <code>reading-order</code> are the CSS Display Level 4 answer, and they are in browsers now.</p>
<h2 id="the-disconnect-in-eight-lines" tabindex="-1">The disconnect, in eight lines <a class="header-anchor" href="#the-disconnect-in-eight-lines" aria-label="Permalink to &quot;The disconnect, in eight lines&quot;">&ZeroWidthSpace;</a></h2>
<p>Here is the smallest version of the problem. Three links in a flex container, reversed, with one of them reordered on top of that:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">box</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> flex</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  flex-direction:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> row-reverse</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">box</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> :</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">nth-child</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  order:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Visually you read <strong>One, Three, Two</strong>. Tab through it and you get One, Two, Three, because that is the source order. Nothing is broken according to the spec, and everything is broken according to the person using a keyboard.</p>
<p>One property fixes it:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">box</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  reading-flow</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> flex-visual</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Focus order becomes One, Three, Two, matching what is on screen in a left-to-right writing mode. If you would rather keep the reversed intent instead of the visual left-to-right sweep, <code>reading-flow: flex-flow</code> gives you Two, Three, One. Both respect <code>order</code>.</p>
<h2 id="picking-the-right-keyword" tabindex="-1">Picking the right keyword <a class="header-anchor" href="#picking-the-right-keyword" aria-label="Permalink to &quot;Picking the right keyword&quot;">&ZeroWidthSpace;</a></h2>
<p><code>reading-flow</code> is set on the <strong>container</strong>, and the value you want depends on the layout it creates:</p>
<ul>
<li><code>normal</code> (the default) keeps DOM order, exactly as before.</li>
<li><code>flex-visual</code> follows the visual order of flex items, accounting for writing mode.</li>
<li><code>flex-flow</code> follows the flex-flow direction.</li>
<li><code>grid-rows</code> walks the grid visually, row by row.</li>
<li><code>grid-columns</code> walks it column by column.</li>
<li><code>grid-order</code> follows the modified order when <code>order</code> is applied to grid items, and behaves like <code>normal</code> when it is not.</li>
<li><code>source-order</code> works in grid, flex, and block containers, and is the one that enables manual overrides.</li>
</ul>
<p>That last one pairs with <code>reading-order</code>, which is set on the <strong>item</strong> and takes an integer. It is the escape hatch for the case no keyword describes, such as an absolutely positioned item in a block container that visually sits above everything else:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">wrapper</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  display:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> block</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  reading-flow</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> source-order</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">wrapper</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> .</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">top</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  reading-order</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> -1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Focus visits <code>.top</code> first, then falls back to source order for the rest. <code>reading-order</code> only does anything inside a container whose <code>reading-flow</code> is not <code>normal</code>, so the two are always used together.</p>
<h2 id="why-this-is-not-just-tabindex-with-extra-steps" tabindex="-1">Why this is not just tabindex with extra steps <a class="header-anchor" href="#why-this-is-not-just-tabindex-with-extra-steps" aria-label="Permalink to &quot;Why this is not just tabindex with extra steps&quot;">&ZeroWidthSpace;</a></h2>
<p>You could approximate the flex example with <code>tabindex=&quot;1&quot;</code>, <code>tabindex=&quot;2&quot;</code>, <code>tabindex=&quot;3&quot;</code> plus an <code>aria-owns</code> attribute to keep the accessibility tree in sync. People have been doing exactly that for years, and it is fragile for a specific reason: positive <code>tabindex</code> values are document-global. Another component elsewhere on the page with <code>tabindex=&quot;1&quot;</code> joins the same bucket, and focus starts teleporting between unrelated regions of the page.</p>
<p><code>reading-flow</code> avoids that by making the container a <strong>focus scope owner</strong>. Sequential navigation visits every focusable element inside the container before moving on, and the direct children are ordered by the property rather than by any positive <code>tabindex</code> on them, which is ignored for ordering purposes. The reordering also applies to how the container's children are exposed to assistive tech, not just to the Tab key, so the screen reader and the keyboard agree.</p>
<p>One sharp edge worth knowing: an element with <code>display: contents</code> inherits <code>reading-flow</code> from its layout parent and becomes a valid reading flow container itself. If you use <code>display: contents</code> as a wrapper, check your focus order after adding this.</p>
<h2 id="where-support-stands" tabindex="-1">Where support stands <a class="header-anchor" href="#where-support-stands" aria-label="Permalink to &quot;Where support stands&quot;">&ZeroWidthSpace;</a></h2>
<p>Chrome has shipped both properties since version 137, Safari added support in 26.4, and Firefox still has it behind a flag as of September 2026 — so this is not Baseline yet. It degrades cleanly, though: browsers that do not understand <code>reading-flow</code> simply keep using DOM order, which is what they do today.</p>
<p>That makes it a real progressive enhancement, with one caveat. <code>reading-flow</code> is a fix for the cases where source order genuinely cannot match the visual layout, not a license to stop caring about markup order. Get the DOM close to right first, then reach for this to close the gap that grid and flexbox opened. The MDN pages for <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/reading-flow" target="_blank" rel="noreferrer">reading-flow</a> and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/reading-order" target="_blank" rel="noreferrer">reading-order</a> have the full value tables, and Chrome's <a href="https://chrome.dev/reading-flow-examples/" target="_blank" rel="noreferrer">reading-flow examples</a> are worth tabbing through with your eyes closed.</p>
]]></content:encoded>
            <category>css</category>
            <category>accessibility</category>
            <enclosure url="https://danholloran.me/images/posts/css-reading-flow-fix-the-tab-order-your-layout-broke.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[WebNN: The Only Web API That Can Reach Your NPU]]></title>
            <link>https://danholloran.me/posts/webnn-the-only-web-api-that-can-reach-your-npu</link>
            <guid isPermaLink="false">https://danholloran.me/posts/webnn-the-only-web-api-that-can-reach-your-npu</guid>
            <pubDate>Mon, 14 Sep 2026 09:07:42 GMT</pubDate>
            <description><![CDATA[Almost every laptop shipped in the last two years has a neural processing unit sitting idle. WebNN is the only web standard that can actually talk to it…]]></description>
            <content:encoded><![CDATA[<p>There is a piece of silicon in your laptop that your web app has never once used. If you bought a machine in the last couple of years, it almost certainly has an NPU: a small, power-efficient accelerator built specifically for running neural networks. Your OS uses it. Native apps use it. The browser, until recently, had no way to reach it at all.</p>
<p>WebGPU got us closer. Running a model through WebGPU means real hardware acceleration instead of grinding through WASM on the CPU, and for a lot of workloads that is the right answer. But WebGPU targets the GPU, which is the part of your machine that is also busy compositing the page, decoding video, and draining the battery. The NPU exists precisely because inference on a GPU is fast but expensive. WebNN is the standard that closes that gap, and it is currently the only web API that provides access to an NPU.</p>
<h2 id="where-the-spec-actually-stands" tabindex="-1">Where the spec actually stands <a class="header-anchor" href="#where-the-spec-actually-stands" aria-label="Permalink to &quot;Where the spec actually stands&quot;">&ZeroWidthSpace;</a></h2>
<p>The W3C published an updated <a href="https://www.w3.org/TR/webnn/" target="_blank" rel="noreferrer">Candidate Recommendation of the Web Neural Network API</a> on 22 January 2026, following more than a hundred significant changes since the previous snapshot in April 2024. That is a meaningful signal: the operator set and the graph model have stopped churning enough that browser vendors are being formally invited to implement and test.</p>
<p>Support today is Chromium-only, across ChromeOS, Linux, macOS, Windows, and Android. Chrome and Edge have working implementations. Firefox and Safari do not, and the spec needs two independent implementations passing the test suite before it can advance to a full Recommendation. So this is a progressive-enhancement story, not a &quot;rewrite your inference layer&quot; story. Feature-detect and fall back:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">async</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> pickBackend</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">ml</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> in</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> navigator</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">))</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">wasm</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  for</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> deviceType</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> of</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">npu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">gpu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">])</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    try</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> navigator</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">ml</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">createContext</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> deviceType </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">      return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> `</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">webnn:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">${</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">deviceType</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">`</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> catch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">      // context creation throws when that device isn't available</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  return</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">webgpu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> in</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> navigator </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">webgpu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> :</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">wasm</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><h2 id="the-graph-model-and-why-it-suits-an-npu" tabindex="-1">The graph model, and why it suits an NPU <a class="header-anchor" href="#the-graph-model-and-why-it-suits-an-npu" aria-label="Permalink to &quot;The graph model, and why it suits an NPU&quot;">&ZeroWidthSpace;</a></h2>
<p>WebNN is not a tensor library. You do not write a training loop against it. You describe a computational graph once, hand it to the browser to compile, and then execute it repeatedly with different inputs. <code>MLGraphBuilder</code> is the factory that builds that graph:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> context</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> navigator</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">ml</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">createContext</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> deviceType</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">npu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> builder</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> MLGraphBuilder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">context</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> descriptor</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dataType</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">float32</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> shape</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 224</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 224</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> };</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> input</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> builder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">input</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">input</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> descriptor</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> weights</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> builder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">constant</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> dataType</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">float32</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> shape</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">32</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 3</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Float32Array</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">weightData</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> conv</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> builder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">conv2d</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">input</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> weights</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> padding</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> output</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> builder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">relu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">conv</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> graph</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> builder</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">build</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> output </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>The build step is where the value lives. Because the browser sees the whole graph before it runs anything, it can fuse operations, pick layouts, and hand the result to whatever accelerator the platform exposes — DirectML on Windows, Core ML on Apple platforms, NNAPI-style paths on Android. That whole-graph view is exactly what NPU drivers want, and it is why an imperative, op-by-op API could never have targeted this hardware well.</p>
<h2 id="you-probably-want-this-through-onnx-runtime-web" tabindex="-1">You probably want this through ONNX Runtime Web <a class="header-anchor" href="#you-probably-want-this-through-onnx-runtime-web" aria-label="Permalink to &quot;You probably want this through ONNX Runtime Web&quot;">&ZeroWidthSpace;</a></h2>
<p>Writing graphs by hand is fine for a demo and miserable for a real model. In practice you reach WebNN through a runtime. ONNX Runtime Web exposes it as an execution provider, so switching backends is a config change:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> session</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ort</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">InferenceSession</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">create</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./model.onnx</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  executionProviders</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> name</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">webnn</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> deviceType</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">npu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> powerPreference</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">default</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">webgpu</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">wasm</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  ]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  freeDimensionOverrides</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> batch</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> channels</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> height</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 224</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> width</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 224</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>Two caveats worth internalizing before you ship this. First, operator coverage: all ONNX operators are supported by the WASM backend, but only a subset are supported by WebGL, WebGPU, and WebNN. Unsupported ops fall back to WASM, which means a model that looks like it is running on the NPU may be silently ping-ponging between backends and performing worse than either pure path. Profile the real model on real hardware; do not trust the backend name in your config. Second, <code>freeDimensionOverrides</code> is not optional decoration — WebNN wants static shapes, and a model with dynamic dimensions will often refuse to compile until you pin them.</p>
<h2 id="is-it-worth-it-yet" tabindex="-1">Is it worth it yet? <a class="header-anchor" href="#is-it-worth-it-yet" aria-label="Permalink to &quot;Is it worth it yet?&quot;">&ZeroWidthSpace;</a></h2>
<p>If you are already shipping in-browser inference, WebNN is worth wiring in as the first entry in your execution-provider list. The fallback chain costs you a few lines and the win on a machine with a real NPU is not just speed, it is power: inference that does not spin up the GPU and does not tank battery life on a laptop that is not plugged in.</p>
<p>If you are not shipping inference yet, this is not the reason to start. Both the WebGPU and WebNN backends are still described as experimental, coverage is Chromium-only, and the operator gaps are real. But the direction is clear enough that it belongs on your radar. The <a href="https://onnxruntime.ai/docs/tutorials/web/ep-webnn.html" target="_blank" rel="noreferrer">ONNX Runtime WebNN docs</a> are the fastest way to try it on a model you already have.</p>
]]></content:encoded>
            <category>javascript</category>
            <category>web-apis</category>
            <category>performance</category>
            <category>ai-ml-in-the-browser</category>
            <enclosure url="https://danholloran.me/images/posts/webnn-the-only-web-api-that-can-reach-your-npu.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[GraphQL @oneOf: Exactly One Input, Enforced by the Schema]]></title>
            <link>https://danholloran.me/posts/graphql-oneof-exactly-one-input-enforced-by-the-schema</link>
            <guid isPermaLink="false">https://danholloran.me/posts/graphql-oneof-exactly-one-input-enforced-by-the-schema</guid>
            <pubDate>Sun, 13 Sep 2026 09:07:54 GMT</pubDate>
            <description><![CDATA[OneOf Input Objects landed in the September 2025 GraphQL spec, which means the exactly-one-of-these-arguments rule you've been enforcing in resolver code is…]]></description>
            <content:encoded><![CDATA[<p>Every GraphQL schema I've worked on eventually grows a field that can be looked up more than one way. You want a user by ID, or by email, or by username. The type system has no way to say &quot;exactly one of these,&quot; so you pick one of two bad options: three root fields that do the same thing, or one field with three nullable arguments and a pile of validation at the top of the resolver.</p>
<p>The second option is the one most teams land on, and it's the one that rots. The schema advertises three optional arguments, which is a lie — two of the three combinations are errors. Your introspection-driven tooling can't see that. Your generated TypeScript types can't see it either, so the client happily compiles code that sends all three and finds out at runtime.</p>
<p>OneOf Input Objects fix this, and as of the <a href="https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects" target="_blank" rel="noreferrer">September 2025 edition of the spec</a> they are no longer an experiment you have to opt into.</p>
<h2 id="the-directive-is-the-whole-feature" tabindex="-1">The directive is the whole feature <a class="header-anchor" href="#the-directive-is-the-whole-feature" aria-label="Permalink to &quot;The directive is the whole feature&quot;">&ZeroWidthSpace;</a></h2>
<p>You mark an input object with <code>@oneOf</code> and the executor enforces that callers supply exactly one field, with a non-null value:</p>
<div class="language-graphql vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">graphql</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">input</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> UserBy </span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">@oneOf</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  id</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ID</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  email</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> String</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  username</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> String</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">type</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Query </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  user</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">by</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> UserBy</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">):</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> User</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Three root fields collapse into one. The constraint lives in the schema instead of in a guard clause, and validation happens before your resolver runs.</p>
<p>Two rules matter when you're writing these. Fields on a <code>@oneOf</code> input must be nullable, and they must not declare defaults. Both fall out of the semantics: a non-null field would be required, which contradicts &quot;pick one,&quot; and a default would silently supply a second value. If you try it, your server will reject the schema at build time rather than at query time.</p>
<p>It's worth noting the constraint is about the field being <em>provided</em>, not about it being truthy. Passing <code>{ id: null }</code> is a validation error, not a lookup for a null ID. That distinction bites people migrating from hand-rolled validation, where <code>null</code> and &quot;absent&quot; usually got collapsed into the same branch.</p>
<h2 id="it-s-not-just-scalars" tabindex="-1">It's not just scalars <a class="header-anchor" href="#it-s-not-just-scalars" aria-label="Permalink to &quot;It's not just scalars&quot;">&ZeroWidthSpace;</a></h2>
<p>The more interesting use is polymorphic input. GraphQL has had union types on the output side since forever and nothing equivalent on the input side. <code>@oneOf</code> is the closest thing we have:</p>
<div class="language-graphql vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">graphql</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">type</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Mutation </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  createPost</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">elements</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">PostElementInput</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">):</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Post</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">input</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> PostElementInput </span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">@oneOf</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  paragraph</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ParagraphInput</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  blockquote</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> BlockQuoteInput</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  gallery</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> GalleryInput</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">input</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> ParagraphInput </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  text</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> String</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">input</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> GalleryInput </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  imageUrls</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">String</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">!</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  caption</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> String</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>A block editor sends a heterogeneous list of elements, each one tagged by which field it occupies, and every branch keeps its own required fields. Before this, that shape was a <code>JSON</code> scalar with a comment above it apologizing.</p>
<p>One sharp edge: recursive <code>@oneOf</code> inputs are only valid if some branch can terminate. An input whose single field points back at itself has no finite value a client could ever send, and a spec-compliant server will reject it. If you need recursion, give it an escape hatch — a scalar branch, or route the cycle through a regular nullable input field.</p>
<h2 id="the-client-story-is-still-catching-up" tabindex="-1">The client story is still catching up <a class="header-anchor" href="#the-client-story-is-still-catching-up" aria-label="Permalink to &quot;The client story is still catching up&quot;">&ZeroWidthSpace;</a></h2>
<p>Server support is broad. GraphQL.js v16+, GraphQL Ruby v2.0.21+, GraphQL Java v21.2+, GraphQL.NET v8+, HotChocolate v16+, Strawberry v0.230.0+, graphql-core v3.3.0+, and webonyx/graphql-php v15.21.0+ all ship it. GraphQL.js v17 tightened coercion further, so schemas that quietly relied on ambiguous inputs will now fail earlier and with better messages.</p>
<p>Codegen is the weaker link. The ideal output is a discriminated union:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">type</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> UserBy</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  |</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> email</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> never</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> username</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> never </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  |</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> never</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> email</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> username</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> never </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  |</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> id</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> never</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> email</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">?:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> never</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> username</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> string </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">};</span></span></code></pre>
</div><p>That's what you want, because TypeScript will then reject the two-field call at compile time. Whether you actually get it depends on your generator and its config — support has been uneven, and combinations like <code>@oneOf</code> plus the <code>interface</code> output setting have known rough edges. Check what your pipeline emits before assuming the guarantee reaches your client code.</p>
<p>If you're designing a new lookup or mutation input this week, reach for <code>@oneOf</code> first. It's a backward-compatible addition, existing clients keep working, and it moves a rule out of your resolver and into the one place every consumer of your API can already see.</p>
]]></content:encoded>
            <category>graphql</category>
            <category>typescript</category>
            <category>web-apis</category>
            <category>tooling</category>
            <enclosure url="https://danholloran.me/images/posts/graphql-oneof-exactly-one-input-enforced-by-the-schema.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[node --test: The Test Runner You Already Have Installed]]></title>
            <link>https://danholloran.me/posts/node-test-the-test-runner-you-already-have-installed</link>
            <guid isPermaLink="false">https://danholloran.me/posts/node-test-the-test-runner-you-already-have-installed</guid>
            <pubDate>Thu, 10 Sep 2026 09:11:23 GMT</pubDate>
            <description><![CDATA[Node's built-in test runner has been stable since v20 and now handles mocking, coverage, watch mode, and TypeScript files.]]></description>
            <content:encoded><![CDATA[<p>Every new Node library starts the same way. <code>npm init</code>, write two functions, and then spend twenty minutes deciding between Vitest and Jest for a package that has no browser code, no JSX, and no transform pipeline worth speaking of. You install a test framework, a config file, and a transitive dependency tree that dwarfs the thing you're actually testing.</p>
<p>Node has shipped a test runner since v18. It went stable in v20. Most of us kept reaching for the npm install anyway, mostly out of habit, partly because the early version really was thin. That's no longer a fair read of it. The 2026 version has mocking, fake timers, watch mode, coverage output, global setup hooks, and it runs your <code>.ts</code> files without a build step. For a backend library, it's frequently enough.</p>
<h2 id="what-you-get-for-zero-dependencies" tabindex="-1">What you get for zero dependencies <a class="header-anchor" href="#what-you-get-for-zero-dependencies" aria-label="Permalink to &quot;What you get for zero dependencies&quot;">&ZeroWidthSpace;</a></h2>
<p>Run <code>node --test</code> in a project with no arguments and it walks the tree looking for files matching a fixed set of patterns: <code>**/*.test.js</code>, <code>**/*-test.js</code>, <code>**/*_test.js</code>, <code>**/test-*.js</code>, <code>**/test.js</code>, and anything under a <code>**/test/</code> directory. The <code>.cjs</code> and <code>.mjs</code> variants are included too. So are the TypeScript equivalents (<code>.ts</code>, <code>.cts</code>, <code>.mts</code>), unless you pass <code>--no-strip-types</code>.</p>
<p>That last part matters more than it sounds. You can write this and run it directly:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> describe</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> it </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">node:test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> assert </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">node:assert/strict</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> parseDuration </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">./duration.ts</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">describe</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">parseDuration</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  it</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">handles compound units</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    assert</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">equal</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">parseDuration</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">1h30m</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 5_400_000</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  });</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">  it</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">throws on garbage</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">    assert</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">throws</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> parseDuration</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">soon</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">invalid duration</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">/</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>No <code>ts-node</code>, no <code>tsx</code>, no build step. Node strips the type annotations and runs the result. The important caveat: it strips, it does not check. You still need <code>tsc --noEmit</code> in CI if you want type errors to fail the build. Type stripping is an execution strategy, not a type checker.</p>
<p>By default each test file runs in its own child process, which gives you real isolation between files without any config. The programmatic <code>run()</code> API exposes this as <code>isolation: 'process' | 'none'</code> if you need to flip it.</p>
<h2 id="the-subtest-gotcha-that-catches-everyone" tabindex="-1">The subtest gotcha that catches everyone <a class="header-anchor" href="#the-subtest-gotcha-that-catches-everyone" aria-label="Permalink to &quot;The subtest gotcha that catches everyone&quot;">&ZeroWidthSpace;</a></h2>
<p>This is the one thing worth internalizing before you migrate anything. Tests created inside a bare <code>test()</code> do not wait for their subtests. Suites do.</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Broken: the parent finishes before the subtest resolves,</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// and the outstanding subtest is cancelled and marked as a failure.</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">user flow</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">creates the user</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createUser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">// Correct: await each subtest.</span></span>
<span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">user flow</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">creates the user</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> createUser</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">();</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  });</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>Inside <code>describe()</code>, siblings are enqueued together and you don't need the <code>await</code>. If you're coming from Jest or Vitest, where nesting always just works, this asymmetry will bite you exactly once and then never again. <code>describe</code>/<code>it</code> is an alias pair for <code>suite</code>/<code>test</code>, so picking the suite style sidesteps the problem entirely.</p>
<h2 id="mocking-and-fake-timers-are-already-there" tabindex="-1">Mocking and fake timers are already there <a class="header-anchor" href="#mocking-and-fake-timers-are-already-there" aria-label="Permalink to &quot;Mocking and fake timers are already there&quot;">&ZeroWidthSpace;</a></h2>
<p>The part people most often assume is missing. <code>mock.fn()</code> gives you a spy with call metadata, and <code>t.mock.method()</code> patches an object method and auto-restores it when the test ends, which is the behavior you want and rarely get for free.</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">test</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">retries on failure</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> async</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> (</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">mock</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">timers</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">enable</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">({</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> apis</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> [</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">setTimeout</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> });</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> fetchSpy</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">mock</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">method</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">client</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">fetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  fetchSpy</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">mock</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">mockImplementationOnce</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Promise</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">reject</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">new</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> Error</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">503</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  const</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> result</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> withRetry</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> client</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">fetch</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">/health</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  t</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">mock</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">timers</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">tick</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1000</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  await</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> result</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  assert</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">equal</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">fetchSpy</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">mock</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">callCount</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 2</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">});</span></span></code></pre>
</div><p>One sharp edge on timers: destructured imports like <code>import { setTimeout } from 'node:timers'</code> are not mockable. Reference the timer functions off the global or the module namespace and it works.</p>
<h2 id="where-it-still-isn-t-vitest" tabindex="-1">Where it still isn't Vitest <a class="header-anchor" href="#where-it-still-isn-t-vitest" aria-label="Permalink to &quot;Where it still isn't Vitest&quot;">&ZeroWidthSpace;</a></h2>
<p>Coverage is real but still behind <code>--experimental-test-coverage</code>. It works, and the lcov reporter plugs straight into Codecov or SonarQube:</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">node</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> --test</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> --experimental-test-coverage</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> \</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  --test-reporter=lcov</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> --test-reporter-destination=lcov.info</span></span></code></pre>
</div><p>Note that the lcov reporter emits no human-readable results, so pair it with a second reporter in CI. Global setup and teardown landed in v24 via <code>--test-global-setup &lt;path&gt;</code>, pointing at a module that exports <code>globalSetup</code> and <code>globalTeardown</code> functions, but it's still marked early development. <code>--watch</code> is likewise experimental.</p>
<p>And there's no JSDOM, no browser mode, no snapshot ecosystem to speak of, no plugin API. If you're testing React components, keep Vitest.</p>
<p>The honest heuristic: for a server-side library, a CLI, or anything where the test story is &quot;call a function, assert on the result,&quot; <code>node --test</code> is probably enough, and every dependency you don't add is one you don't have to audit later. Start with the built-in runner and let the project tell you when it's outgrown it.</p>
]]></content:encoded>
            <category>node</category>
            <category>testing</category>
            <category>tooling</category>
            <category>node.js</category>
            <enclosure url="https://danholloran.me/images/posts/node-test-the-test-runner-you-already-have-installed.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Svelte Snippets: Reuse Markup Without a New Component]]></title>
            <link>https://danholloran.me/posts/svelte-snippets-reuse-markup-without-a-new-component</link>
            <guid isPermaLink="false">https://danholloran.me/posts/svelte-snippets-reuse-markup-without-a-new-component</guid>
            <pubDate>Mon, 07 Sep 2026 09:07:25 GMT</pubDate>
            <description><![CDATA[Snippets let you define reusable chunks of markup inline and render them like functions. They replace slots, kill the let: directive, and mean you stop…]]></description>
            <content:encoded><![CDATA[<p>You have a card layout that appears twice in the same component: once wrapped in a link, once bare. The markup is identical apart from the wrapper. For years the Svelte answer was to extract <code>Card.svelte</code>, import it, thread props through it, and accept a new file in your tree for six lines of HTML.</p>
<p>That works, but it is a heavy tool for a light problem. A component brings its own module scope, its own props contract, and its own place in the file system. Sometimes you just want the markup twice. Svelte 5's snippets are the smaller tool, and once you see them as functions that return markup, most of the awkwardness in Svelte's old component-composition story goes away.</p>
<h2 id="a-snippet-is-a-function-and-render-calls-it" tabindex="-1">A snippet is a function, and <code>{@render}</code> calls it <a class="header-anchor" href="#a-snippet-is-a-function-and-render-calls-it" aria-label="Permalink to &quot;A snippet is a function, and `{@render}` calls it&quot;">&ZeroWidthSpace;</a></h2>
<p>The syntax is <code>{#snippet name(params)}...{/snippet}</code> to define, <code>{@render name(args)}</code> to call:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> figure</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">figure</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">img</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> src=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">src</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> alt=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">caption</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> width=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">width</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> height=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">height</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">figcaption</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">caption</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">figcaption</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">figure</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">each</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> images </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">as</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">href</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">a</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> href=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">href</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}>{@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">render</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> figure</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">a</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">else</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    {@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">render</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> figure</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">image</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">each</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Parameters behave like a normal function signature: any number of them, destructuring, default values. The one exception is rest parameters, which are not supported.</p>
<p>Scope is lexical, and this is the part worth internalizing. A snippet can read anything in scope where it was declared — <code>&lt;script&gt;</code> variables, the current <code>{#each}</code> item — and it is visible to its siblings and their children, but not to anything above it. Declare a snippet inside a <code>&lt;div&gt;</code> and you cannot render it outside that <code>&lt;div&gt;</code>. Snippets can also reference themselves, which makes recursive markup pleasant instead of a <code>&lt;svelte:self&gt;</code> puzzle:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> countdown</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">n</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> n </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">></span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">span</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">n</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">...</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">span</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    {@</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">render</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> countdown</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">n </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">-</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {:</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">else</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">span</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">🚀</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">span</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">if</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><h2 id="they-replace-slots-and-they-take-the-let-directive-with-them" tabindex="-1">They replace slots, and they take the <code>let:</code> directive with them <a class="header-anchor" href="#they-replace-slots-and-they-take-the-let-directive-with-them" aria-label="Permalink to &quot;They replace slots, and they take the `let:` directive with them&quot;">&ZeroWidthSpace;</a></h2>
<p>Snippets are values, so passing markup into a component is just passing a prop. There are three shapes for this. You can pass a snippet explicitly like any other prop, declare snippets directly inside the component's tags (Svelte turns those into props automatically), or write plain content inside the tags, which becomes the implicit <code>children</code> snippet:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Table</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> data=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">fruits</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> header</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">th</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">fruit</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">th</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">th</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">qty</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">th</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">th</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">price</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">th</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> row</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">d</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">td</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">d</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">name</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">td</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">td</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">d</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">qty</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">td</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">td</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">d</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">price</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">td</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">Table</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>Inside <code>Table.svelte</code>, those arrive as ordinary props: <code>let { data, header, row } = $props()</code>, then <code>{@render row(d)}</code> in the loop. Optional ones use <code>{@render children?.()}</code>, or an <code>{#if}</code> block when you want fallback content.</p>
<p>Compare that to the Svelte 4 version, where <code>&lt;slot name=&quot;row&quot; let:item /&gt;</code> introduced a variable through a directive whose colon meant the opposite of every other colon in the template, and where the variable from one slot was invisible inside a sibling slot. Snippets are functions, so their parameters are just parameters. Slots still work in Svelte 5 but are deprecated.</p>
<p>If you are migrating, budget for two rough edges. <code>&lt;slot name=&quot;header&quot; /&gt;</code> becomes <code>{@render header?.()}</code>, but the corresponding <code>&lt;div slot=&quot;header&quot;&gt;</code> on the consumer side is silently ignored rather than erroring — the content simply vanishes. And <code>&lt;Component let:prop&gt;</code> now throws, as does forwarding a slot with directives attached (<code>&lt;slot name=&quot;a&quot; slot=&quot;a&quot; let:abc&gt;</code>), which was legal in Svelte 4.</p>
<h2 id="typing-and-the-edges" tabindex="-1">Typing and the edges <a class="header-anchor" href="#typing-and-the-edges" aria-label="Permalink to &quot;Typing and the edges&quot;">&ZeroWidthSpace;</a></h2>
<p>Snippets have a real type. Import <code>Snippet</code> from <code>svelte</code> and give it a tuple of its parameters:</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">import</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> type</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> Snippet </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> from</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> "</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">interface</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Props</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  data</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> unknown</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">[];</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  children</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  row</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;[</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">unknown</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">]>;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span></code></pre>
</div><p>Add <code>generics=&quot;T&quot;</code> to the <code>&lt;script lang=&quot;ts&quot;&gt;</code> tag and you can tie <code>data: T[]</code> to <code>row: Snippet&lt;[T]&gt;</code>, so the consumer gets a type error for a mismatched row. That is strictly better than what slots ever offered.</p>
<p>Two extras worth knowing. Top-level snippets can be exported from a <code>&lt;script module&gt;</code> block and imported into other components, as long as they do not touch instance-level state (Svelte 5.5.0 and up). And <code>createRawSnippet</code> builds one programmatically, which you will almost never need but which exists when a library has to.</p>
<p>The rule of thumb: reach for a snippet when you are repeating markup, and for a component when the chunk has its own state, its own styles, or its own reason to be tested. The <a href="https://svelte.dev/docs/svelte/snippet" target="_blank" rel="noreferrer">snippet docs</a> cover the remaining corners.</p>
]]></content:encoded>
            <category>svelte</category>
            <category>javascript</category>
            <category>typescript</category>
            <enclosure url="https://danholloran.me/images/posts/svelte-snippets-reuse-markup-without-a-new-component.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[CSS scroll-state() Queries: Styling Stuck, Snapped, and Scrollable]]></title>
            <link>https://danholloran.me/posts/css-scroll-state-queries-styling-stuck-snapped-and-scrollable</link>
            <guid isPermaLink="false">https://danholloran.me/posts/css-scroll-state-queries-styling-stuck-snapped-and-scrollable</guid>
            <pubDate>Sun, 06 Sep 2026 09:07:15 GMT</pubDate>
            <description><![CDATA[Sticky headers, snapped carousel slides, and scroll shadows have all been JavaScript jobs for a decade.]]></description>
            <content:encoded><![CDATA[<p>Every codebase I have worked in has the same file somewhere. It is called <code>stickyHeader.js</code> or <code>useIsStuck.ts</code>, and it exists because CSS could tell you an element was <code>position: sticky</code> but never whether it was currently <em>stuck</em>. So you wire up an IntersectionObserver against a one-pixel sentinel div, toggle a class, and hope nobody asks why the shadow flickers on iOS.</p>
<p>The browser has always known the answer. It has to know: it is the thing doing the sticking, the snapping, and the overflowing. Scroll-state container queries are the API that finally exposes that knowledge to the stylesheet.</p>
<h2 id="the-three-states-and-the-container-that-owns-them" tabindex="-1">The three states, and the container that owns them <a class="header-anchor" href="#the-three-states-and-the-container-that-owns-them" aria-label="Permalink to &quot;The three states, and the container that owns them&quot;">&ZeroWidthSpace;</a></h2>
<p>The mental model is the one you already have from container queries. You mark an element as a container, then style its <strong>descendants</strong> based on the container's state. The only new part is a <code>container-type</code> value:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">header-wrapper</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  container-type:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> scroll-state</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  position:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> sticky</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  top:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">header-wrapper</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> nav</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  transition:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> box-shadow </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">s</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> ease</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  @</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> scroll-state(stuck</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> top</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">) {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    box-shadow:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 4</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 12</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">px</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> rgb</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> / </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.15</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">}</span></span></code></pre>
</div><p>That is the whole sentinel-div dance, gone. Three descriptors cover most of what you would reach for JavaScript to do:</p>
<ul>
<li><code>stuck: top | right | bottom | left</code> — the sticky element is currently pinned to that edge</li>
<li><code>snapped: x | y | inline | block</code> — this scroll-snap target is the one the scroller has snapped to</li>
<li><code>scrollable: top | right | bottom | left</code> — there is more content to scroll in that direction</li>
</ul>
<p>There is also a fourth, <code>scrolled</code>, which reports the direction of the most recent scroll and shipped later than the other three (Chrome 144, January 2026).</p>
<p>The rule that trips everyone up on their first attempt: <strong>the container cannot style itself</strong>. The element carrying <code>container-type: scroll-state</code> is the sticky or snapping element, and the thing that reacts has to be a child of it. For a snap carousel that means three levels, not two:</p>
<div class="language- vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang"></span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span>scroll container      → scroll-snap-type: x mandatory</span></span>
<span class="line"><span>  └ snap target       → scroll-snap-align + container-type: scroll-state</span></span>
<span class="line"><span>      └ child element → @container scroll-state(snapped: x) { ... }</span></span></code></pre>
</div><h2 id="fading-the-unsnapped-items" tabindex="-1">Fading the unsnapped items <a class="header-anchor" href="#fading-the-unsnapped-items" aria-label="Permalink to &quot;Fading the unsnapped items&quot;">&ZeroWidthSpace;</a></h2>
<p>Carousels where the centered item is full-strength and its neighbours dim used to require a scroll listener or the <code>scrollsnapchange</code> event. With <code>not</code> in the query you can invert it and write the effect as a single rule on the inactive state:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">testimonials</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  overflow-x:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> auto</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  scroll-snap-type:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> x</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> mandatory</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">testimonials</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> article</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  container-type:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> scroll-state</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  scroll-snap-align:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> center</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">testimonials</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> article</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> ></span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> *</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  transition:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> opacity </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.4</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">s</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> ease</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  @</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> not scroll-state(snapped</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> x</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">) {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    opacity:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0.25</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">}</span></span></code></pre>
</div><p>Worth knowing: the snapped query fires like <code>scrollsnapchanging</code>, not <code>scrollsnapchange</code>. It flips as soon as the browser decides which target it is heading for, before the scroll settles. That is usually what you want for visual feedback, and occasionally too eager, in which case the JavaScript event is still the right tool.</p>
<h2 id="scroll-affordances-that-actually-know" tabindex="-1">Scroll affordances that actually know <a class="header-anchor" href="#scroll-affordances-that-actually-know" aria-label="Permalink to &quot;Scroll affordances that actually know&quot;">&ZeroWidthSpace;</a></h2>
<p>The <code>scrollable</code> descriptor solves a problem that was genuinely hard: knowing whether an overflow area has anywhere left to go. Lea Verou's <code>background-attachment: local</code> trick approximated it, and scroll-driven animations approximated it differently, but both were workarounds. Now the gradient just asks:</p>
<div class="language-css vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">css</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">pane</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  container-type:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> scroll-state size</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  overflow:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> auto</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">.</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">pane</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">::</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">after</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  content:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> ""</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  position:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> sticky</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#5C6B74;--shiki-light-font-style:italic;--shiki-dark:#C3C7CA;--shiki-dark-font-style:italic">  /* ...pinned overlay spanning the scrollport... */</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  opacity:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 0</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  transition:</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93"> opacity </span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">0.3</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">s</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> ease</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">  @</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">container</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> scroll-state(scrollable</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> bottom</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">) {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    opacity:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">}</span></span></code></pre>
</div><p>Two details in there are easy to miss. A pseudo-element can query its own originating element's container, which is what makes the single-overlay pattern work. And a single element can be both a <code>size</code> and a <code>scroll-state</code> container, so you are not forced to pick.</p>
<h2 id="where-this-actually-stands" tabindex="-1">Where this actually stands <a class="header-anchor" href="#where-this-actually-stands" aria-label="Permalink to &quot;Where this actually stands&quot;">&ZeroWidthSpace;</a></h2>
<p>Support is Chromium-only. As of August 2026, caniuse puts global coverage around 72% with Chrome and Edge 133+, Opera 118+, and Samsung Internet 29+ shipping it. Safari 27 and Firefox 158 still have not, so this is not Baseline and you should not read a blog post that tells you it is.</p>
<p>That is fine for the use cases above, because every one of them is decoration. A shadow that never appears, a dimmed neighbour that stays bright, a gradient that stays hidden: nothing breaks, the carousel still scrolls, the header still sticks. Wrap the enhancement in <code>@supports (container-type: scroll-state)</code> when you need the fallback to be explicit rather than merely harmless, and put <code>@media (prefers-reduced-motion: no-preference)</code> around anything that moves.</p>
<p>What I would not do yet is delete the JavaScript from a component where the stuck state changes behaviour rather than appearance. For the other 90% of cases, this is a stylesheet-sized replacement for a file you have been maintaining for years.</p>
]]></content:encoded>
            <category>css</category>
            <category>scroll</category>
            <category>web-apis</category>
            <category>animation</category>
            <enclosure url="https://danholloran.me/images/posts/css-scroll-state-queries-styling-stuck-snapped-and-scrollable.jpg" length="0" type="image/jpg"/>
        </item>
        <item>
            <title><![CDATA[Async Svelte: Using await Directly in Your Components]]></title>
            <link>https://danholloran.me/posts/async-svelte-using-await-directly-in-your-components</link>
            <guid isPermaLink="false">https://danholloran.me/posts/async-svelte-using-await-directly-in-your-components</guid>
            <pubDate>Sun, 30 Aug 2026 07:07:38 GMT</pubDate>
            <description><![CDATA[Svelte 5.36 lets you use await at the top level of a component, inside $derived, and in your markup.]]></description>
            <content:encoded><![CDATA[<p>Every Svelte codebase eventually grows a little pile of scaffolding around asynchronous data. A <code>let data = $state(null)</code>, an <code>$effect</code> that fetches and assigns, a <code>loading</code> flag, an <code>error</code> flag, and a <code>{#if loading}</code> in the template. Or the <code>{#await}</code> block, which is fine for one promise but nests badly the moment you need two. Either way, you end up writing plumbing rather than describing your UI.</p>
<p>Since Svelte 5.36, you can skip most of that. The <code>await</code> keyword works in three places it previously did not: at the top level of a component's <code>&lt;script&gt;</code>, inside <code>$derived(...)</code>, and directly in your markup. It is still behind an experimental flag, but the design is worth understanding now because it changes how you think about loading states.</p>
<h2 id="turning-it-on-and-what-changes" tabindex="-1">Turning it on, and what changes <a class="header-anchor" href="#turning-it-on-and-what-changes" aria-label="Permalink to &quot;Turning it on, and what changes&quot;">&ZeroWidthSpace;</a></h2>
<p>Async Svelte is opt-in. Add <code>experimental.async</code> wherever you configure the compiler, which usually means <code>svelte.config.js</code>:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">export</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> default</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">  compilerOptions</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">    experimental</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">      async</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">:</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> true</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">};</span></span></code></pre>
</div><p>The docs say this flag disappears in Svelte 6, so today's opt-in is tomorrow's default. Once it is on, this is legal:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> a </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">state</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">1</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> b </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> $</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA">state</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD">2</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">  async</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> function</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> add</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> {</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    await</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> new</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93"> Promise</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">((</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">f</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> =></span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> setTimeout</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">f</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#337E85;--shiki-dark:#87C4CD"> 500</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">    return</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> a </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">+</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">;</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  }</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">script</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">input</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> type=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">number</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> bind</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">input</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> type=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">number</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">"</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA"> bind</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">value</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB"> /></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> + </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> = </span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">{</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> add</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">a</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">,</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> b</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>The interesting part is what does <em>not</em> happen. Bump <code>a</code> to 2 and the paragraph does not flash <code>2 + 2 = 3</code> while the promise is in flight. Svelte holds the whole update until <code>add(a, b)</code> resolves, then swaps everything at once. That is the headline feature: <strong>synchronized updates</strong>. You never render a torn UI where half the values are new and half are stale, which is exactly the bug that <code>loading</code> flags exist to paper over.</p>
<p>Updates can also overlap. A fast update lands while a slower earlier one is still running, so a quick keystroke is not stuck behind a slow one.</p>
<h2 id="concurrency-is-automatic-waterfalls-are-not" tabindex="-1">Concurrency is automatic, waterfalls are not <a class="header-anchor" href="#concurrency-is-automatic-waterfalls-are-not" aria-label="Permalink to &quot;Concurrency is automatic, waterfalls are not&quot;">&ZeroWidthSpace;</a></h2>
<p>Two independent <code>await</code> expressions in markup run in parallel, even though they read as sequential:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> one</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">x</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> two</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">y</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>Both kick off immediately. The same is <em>not</em> true inside your <code>&lt;script&gt;</code>, where <code>await</code> behaves like ordinary JavaScript and runs top to bottom. Svelte will warn you about this with an <code>await_waterfall</code> warning when you write something like:</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> a </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> $derived</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> one</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">x</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span>
<span class="line"><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">let</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5"> b </span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">=</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> $derived</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> two</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">y</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">));</span></span></code></pre>
</div><p>Here <code>b</code> is not created until <code>a</code> resolves. Once both exist they update independently, but that first pass is a waterfall. If you have seen the same class of bug in a React <code>useEffect</code> chain, this is the familiar shape with a compiler warning attached.</p>
<h2 id="loading-states-move-into-boundaries" tabindex="-1">Loading states move into boundaries <a class="header-anchor" href="#loading-states-move-into-boundaries" aria-label="Permalink to &quot;Loading states move into boundaries&quot;">&ZeroWidthSpace;</a></h2>
<p>With no <code>loading</code> variable to hang a spinner on, placeholder UI moves to <code>&lt;svelte:boundary&gt;</code> and its <code>pending</code> snippet:</p>
<div class="language-svelte vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">svelte</span><pre class="shiki shiki-themes Grimicorn Light Grimicorn Dark vp-code" tabindex="0" v-pre="" style="--shiki-light-bg:#FDFDFD;--shiki-dark-bg:#3C4C55;"><code><span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">>{</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">await</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> delayed</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">(</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#2E7D32;--shiki-dark:#A9CE93">hello!</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">'</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">)}&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {#</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#6B63C8;--shiki-dark:#BAB5EA"> pending</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">()}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">    &#x3C;</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span><span style="--shiki-light:#1A262C;--shiki-dark:#E5E5E5">loading...</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">p</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">  {/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">snippet</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">}</span></span>
<span class="line"><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">&#x3C;/</span><span style="--shiki-light:#4475B7;--shiki-dark:#99BDEA">svelte</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">:</span><span style="--shiki-light:#77771C;--shiki-dark:#DADA93">boundary</span><span style="--shiki-light:#1A262CBB;--shiki-dark:#E5E5E5BB">></span></span></code></pre>
</div><p>The <code>pending</code> snippet shows when the boundary is first created and stays until every <code>await</code> inside it resolves. It deliberately does not reappear for later updates, since those are globally coordinated and rendering a full-page skeleton on every keystroke would be worse than useless.</p>
<p>For subsequent async work, <code>$effect.pending()</code> tells you how many promises are outstanding in the current boundary, not counting child boundaries. That is what you reach for when you want a small &quot;validating...&quot; spinner next to a form field rather than blanking the section. There is also <code>settled()</code>, a promise that resolves once state changes and their async consequences have been flushed to the DOM.</p>
<p>Errors get the same treatment. Anything thrown inside an <code>await</code> expression bubbles to the nearest boundary, where a <code>failed</code> snippet receives the <code>error</code> and a <code>reset</code> function. Worth remembering: boundaries catch errors during rendering and effects, not errors from event handlers or a stray <code>setTimeout</code>.</p>
<h2 id="the-parts-still-in-motion" tabindex="-1">The parts still in motion <a class="header-anchor" href="#the-parts-still-in-motion" aria-label="Permalink to &quot;The parts still in motion&quot;">&ZeroWidthSpace;</a></h2>
<p>This is experimental, and the docs are direct about it: the details of <code>await</code> handling and <code>$effect.pending()</code> can change outside a semver major. Effect ordering already shifts when the flag is on, with block effects like <code>{#if}</code> and <code>{#each}</code> running before <code>$effect.pre</code> in the same component.</p>
<p>Server rendering works through an awaited <code>render(...)</code>, though frameworks handle that for you. Today a boundary with a <code>pending</code> snippet renders that snippet during SSR and skips its contents, with streaming planned but not shipped. Svelte 5.42 also added <code>fork(...)</code>, which speculatively runs async work you expect to need soon, and SvelteKit is the intended consumer for preloading on hover or focus.</p>
<p>If you maintain a Svelte app, the useful move right now is not a rewrite. Turn the flag on in a branch, pick one component with the most <code>loading</code> and <code>error</code> bookkeeping, and see how much of it disappears. The <a href="https://svelte.dev/docs/svelte/await-expressions" target="_blank" rel="noreferrer">await expressions docs</a> and the <a href="https://svelte.dev/docs/svelte/svelte-boundary" target="_blank" rel="noreferrer"><code>&lt;svelte:boundary&gt;</code> reference</a> are short enough to read in one sitting.</p>
]]></content:encoded>
            <category>svelte</category>
            <category>javascript</category>
            <category>performance</category>
            <enclosure url="https://danholloran.me/images/posts/async-svelte-using-await-directly-in-your-components.jpg" length="0" type="image/jpg"/>
        </item>
    </channel>
</rss>