<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ahmed Mahmoud</title>
    <description>The latest articles on DEV Community by Ahmed Mahmoud (@ahmed_mahmoud360).</description>
    <link>https://dev.to/ahmed_mahmoud360</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F656404%2F01b9474b-ca4f-4578-a15e-36a90ad96c82.jpeg</url>
      <title>DEV Community: Ahmed Mahmoud</title>
      <link>https://dev.to/ahmed_mahmoud360</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmed_mahmoud360"/>
    <language>en</language>
    <item>
      <title>npm Supply-Chain Hardening in 2026: Lifecycle Scripts, minimumReleaseAge, and the Postinstall I Stopped Trusting</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 14 Sep 2026 06:00:09 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/npm-supply-chain-hardening-in-2026-lifecycle-scripts-minimumreleaseage-and-the-postinstall-i-52b5</link>
      <guid>https://dev.to/ahmed_mahmoud360/npm-supply-chain-hardening-in-2026-lifecycle-scripts-minimumreleaseage-and-the-postinstall-i-52b5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; The npm attacks that actually landed over the past two years executed at install time, through lifecycle scripts, within hours of the malicious version being published. Turning dependency lifecycle scripts off by default, refusing versions younger than a few days, and removing long-lived publish tokens from CI blocks most of that path without changing a line of application code.&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

&lt;p&gt;The first useful step is knowing how many packages in your tree claim that right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# every package in the installed tree that declares an install-time script&lt;/span&gt;
npm query &lt;span class="s2"&gt;":attr(scripts, [postinstall])"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

&lt;p&gt;pnpm's version is narrower and better, because the allow-list is per package rather than all-or-nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pnpm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"onlyBuiltDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"esbuild"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sharp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"better-sqlite3"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The heuristic for that list: native addons and binary downloaders (&lt;code&gt;esbuild&lt;/code&gt;, &lt;code&gt;playwright&lt;/code&gt;) have a real reason to run at install. A pure-JavaScript utility asking for a &lt;code&gt;postinstall&lt;/code&gt; does not, and that request is itself the signal.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt; is a package-manager setting that refuses to install any version published more recently than a given number of minutes. pnpm reads it from &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, Renovate has supported it as a config option for years, and Dependabot exposes the same idea as a cooldown block in &lt;code&gt;dependabot.yml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pnpm-workspace.yaml&lt;/span&gt;
&lt;span class="na"&gt;minimumReleaseAge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4320&lt;/span&gt;          &lt;span class="c1"&gt;# minutes; 3 days&lt;/span&gt;
&lt;span class="na"&gt;minimumReleaseAgeExclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@my-scope/*"&lt;/span&gt;                &lt;span class="c1"&gt;# our own packages publish and deploy same-day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;renovate.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimumReleaseAge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"internalChecksFilter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"strict"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cooldown is disproportionately effective because a malicious npm version has a short shelf life. Registry maintainers, scanners, and other developers find these within hours to a couple of days, and the version gets unpublished or deprecated. The cooldown does not make you smarter than the attacker; it makes you later than the people who find them, which is the same outcome for a fraction of the effort.&lt;/p&gt;

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

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

&lt;p&gt;No. npm provenance is a signed attestation, generated by &lt;code&gt;npm publish --provenance&lt;/code&gt; running in a supported CI provider, that links a published tarball to the source repository, commit, and workflow that built it. It proves origin, not intent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# verify registry signatures and attestations for the installed tree&lt;/span&gt;
npm audit signatures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;&lt;strong&gt;Pin GitHub Actions to a full commit SHA, not a tag.&lt;/strong&gt; Git tags are mutable, and the 2025 &lt;code&gt;tj-actions/changed-files&lt;/code&gt; compromise worked by repointing existing tags at a malicious commit, so everyone pinned to &lt;code&gt;@v4&lt;/code&gt; picked it up automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683&lt;/span&gt; &lt;span class="c1"&gt;# v4.2.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CsvImporter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Worker&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

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

&lt;/div&gt;



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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/workers/parse.worker.ts&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/workers/search.worker.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;comlink&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;buildIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Doc&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;term&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SearchApi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;comlink&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SearchApi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../workers/search.worker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../workers/search.worker.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Comlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wrap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SearchApi&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// touches the DOM, must stay on the main thread&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Turborepo 2.0 renamed the top-level &lt;code&gt;pipeline&lt;/code&gt; key to &lt;code&gt;tasks&lt;/code&gt;, because the old name implied a sequential pipeline when the value is really a set of task definitions whose order comes entirely from &lt;code&gt;dependsOn&lt;/code&gt;. A configuration file still using &lt;code&gt;pipeline&lt;/code&gt; fails on Turborepo 2 with a schema error, and &lt;code&gt;npx @turbo/codemod@latest migrate&lt;/code&gt; renames it along with the other 2.x migrations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://turbo.build/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tui"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"globalEnv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"NODE_ENV"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"$TURBO_DEFAULT$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".env*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!.next/cache/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist/**"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"NEXT_PUBLIC_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"coverage/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"persistent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two pieces of that syntax carry most of the meaning. &lt;code&gt;"dependsOn": ["^build"]&lt;/code&gt; with the caret means "build every package this package depends on first", walking up the dependency graph. &lt;code&gt;"dependsOn": ["build"]&lt;/code&gt; without the caret means "run the build task of this same package first". Mixing those two up produces a graph that reads correctly and schedules wrong.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;p&gt;Next.js inlines every &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variable into the client bundle at build time as a string literal, so the compiled &lt;code&gt;.next&lt;/code&gt; output physically contains the values from whichever environment produced it. If those variables are not listed in the task's &lt;code&gt;env&lt;/code&gt; array, Turborepo's hash cannot see them, the build looks identical to a previous one, and the cache restores a bundle pointing at the wrong API. Nothing throws. The build is green.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"NEXT_PUBLIC_*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NEXTAUTH_URL"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"passThroughEnv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"AWS_SECRET_ACCESS_KEY"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!.next/cache/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turborepo 2.0 changed the default environment mode to &lt;code&gt;strict&lt;/code&gt;, and that default is the real fix. In strict mode a task's process receives only the variables named in &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;globalEnv&lt;/code&gt;, plus a small built-in allowlist. An undeclared variable is therefore &lt;code&gt;undefined&lt;/code&gt; at build time rather than quietly present, which turns an invisible wrong-value bug into an obvious failure you catch locally.&lt;/p&gt;

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@repo/ui"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./src/index.ts"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consuming Next.js app then needs &lt;code&gt;transpilePackages: ['@repo/ui']&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt; so its compiler handles the untranspiled source. The upside is that there is no build task, so there is no cache entry to invalidate and no stale &lt;code&gt;dist/&lt;/code&gt; to debug. The downside is that every consuming app compiles the shared code again, and a NestJS service started with &lt;code&gt;node dist/main.js&lt;/code&gt; cannot import a &lt;code&gt;.ts&lt;/code&gt; file at all.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@repo/core"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsup src/index.ts --format esm --dts"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My rule after living with both: UI components and type-only packages stay Just-in-Time, and anything imported by a Node runtime or published outside the repo gets compiled. Splitting on that line kept the build graph shallow, because most packages have no build task and &lt;code&gt;^build&lt;/code&gt; has almost nothing to wait for.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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




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

</description>
      <category>turborepo</category>
      <category>monorepo</category>
      <category>nextjs</category>
      <category>devops</category>
    </item>
    <item>
      <title>Temporal in Production: Field Notes on Replacing JavaScript Date, the Same-Day Bug, and What Doesn't Cross the RSC Boundary</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Wed, 02 Sep 2026 06:00:12 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/temporal-in-production-field-notes-on-replacing-javascript-date-the-same-day-bug-and-what-20cl</link>
      <guid>https://dev.to/ahmed_mahmoud360/temporal-in-production-field-notes-on-replacing-javascript-date-the-same-day-bug-and-what-20cl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Temporal is the TC39 API that replaces JavaScript's &lt;code&gt;Date&lt;/code&gt; object with immutable, time-zone-aware types. The migration is mechanical almost everywhere, except at two boundaries: a Temporal object cannot be passed as a prop from a Server Component to a Client Component, and it has to become a string again before it reaches a database driver.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have written the same date bug at least four times in my career. A user in Cairo files a report at 22:30 on the 22nd, the serverless function runs in UTC, and the dashboard files it under the 21st. Every time, the fix was a patch on top of &lt;code&gt;Date&lt;/code&gt; — a helper, a library, an offset subtracted somewhere hopeful. This year I stopped patching and moved a Next.js app's date handling onto Temporal instead.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temporal replaces &lt;code&gt;Date&lt;/code&gt;, not &lt;code&gt;Intl&lt;/code&gt;.&lt;/strong&gt; Temporal owns arithmetic, comparison, and time zones; &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; still does the human-facing formatting, and every Temporal object exposes &lt;code&gt;toLocaleString()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the Temporal type by what the value means.&lt;/strong&gt; A birthday is a &lt;code&gt;Temporal.PlainDate&lt;/code&gt;, an audit timestamp is a &lt;code&gt;Temporal.Instant&lt;/code&gt;, and "3pm in Cairo" is a &lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal objects are class instances, so React cannot serialize them across the RSC boundary.&lt;/strong&gt; Call &lt;code&gt;.toString()&lt;/code&gt; in the Server Component and &lt;code&gt;.from()&lt;/code&gt; in the Client Component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every Temporal object is immutable.&lt;/strong&gt; &lt;code&gt;.add()&lt;/code&gt;, &lt;code&gt;.subtract()&lt;/code&gt;, and &lt;code&gt;.with()&lt;/code&gt; return new objects, and &lt;code&gt;===&lt;/code&gt; never compares two Temporal values correctly — use &lt;code&gt;.equals()&lt;/code&gt; or the static &lt;code&gt;compare()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install the &lt;code&gt;temporal-polyfill&lt;/code&gt; package today.&lt;/strong&gt; Native support across browsers and Node is still uneven, and the polyfill lets you write the final API now and delete one import later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does Temporal actually replace?
&lt;/h2&gt;

&lt;p&gt;Temporal replaces the JavaScript &lt;code&gt;Date&lt;/code&gt; object — the mutable, millisecond-based, single-time-zone type that has shipped essentially unchanged since 1995. &lt;code&gt;Date&lt;/code&gt; has three defects that Temporal removes outright.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;Date&lt;/code&gt; is mutable: &lt;code&gt;d.setDate(d.getDate() + 1)&lt;/code&gt; edits an object that other code may still be holding. Second, &lt;code&gt;Date&lt;/code&gt; knows exactly two time zones — UTC and whatever the host machine reports — so a function running in UTC and a browser running in &lt;code&gt;Africa/Cairo&lt;/code&gt; disagree about what "today" means. Third, &lt;code&gt;Date&lt;/code&gt; parsing is inconsistent by specification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-08-22&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// UTC midnight — the date-only form is parsed as UTC&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-08-22T00:00:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// local midnight — a date-time with no offset is local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two lines can differ by hours, and nothing in the code says so. Temporal turns the distinction into a type: &lt;code&gt;Temporal.PlainDate.from('2026-08-22')&lt;/code&gt; is a calendar date with no instant attached, and &lt;code&gt;Temporal.Instant.from('2026-08-22T00:00:00Z')&lt;/code&gt; is an exact point on the timeline. You cannot accidentally use one where you meant the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Temporal type should I use for each field?
&lt;/h2&gt;

&lt;p&gt;Pick the Temporal type from what the value means to the business, not from the database column it happens to live in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the value means&lt;/th&gt;
&lt;th&gt;Temporal type&lt;/th&gt;
&lt;th&gt;String form&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Birthday, invoice due date&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Temporal.PlainDate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-22&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store opening hour&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Temporal.PlainTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;09:00:00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Meeting at 3pm in Cairo"&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-22T15:00:00+03:00[Africa/Cairo]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;created_at&lt;/code&gt;, audit log entry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Temporal.Instant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08-22T12:00:00Z&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session length, cache TTL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Temporal.Duration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PT2H30M&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card expiry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Temporal.PlainYearMonth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-08&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Temporal.Now&lt;/code&gt; is the entry point for the current moment: &lt;code&gt;Temporal.Now.instant()&lt;/code&gt;, &lt;code&gt;Temporal.Now.zonedDateTimeISO(zone)&lt;/code&gt;, and &lt;code&gt;Temporal.Now.plainDateISO(zone)&lt;/code&gt;. That last signature is the API doing its job — asking for today's date forces you to answer "today according to whom?", which is exactly the question &lt;code&gt;new Date()&lt;/code&gt; lets you skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my "is it the same day?" check break for users in another time zone?
&lt;/h2&gt;

&lt;p&gt;A same-day comparison breaks because two instants only fall on the same calendar day &lt;em&gt;relative to a specific time zone&lt;/em&gt;, and &lt;code&gt;Date.prototype.toDateString()&lt;/code&gt; silently picks the host machine's zone. On a serverless function running in UTC, an event at 22:30 in Cairo has already rolled over to tomorrow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: the server's time zone decides what a "day" is&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sameDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Right: name the zone the question is being asked in&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Africa/Cairo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sameDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toZonedDateTimeISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toPlainDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toZonedDateTimeISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toPlainDate&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same explicitness shows up in arithmetic across a daylight-saving transition, where &lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt; separates calendar units from exact units:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zdt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ZonedDateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-28T09:00[Europe/Berlin]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;zdt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;   &lt;span class="c1"&gt;// 2026-03-29T09:00 — same wall clock, 23 real hours later&lt;/span&gt;
&lt;span class="nx"&gt;zdt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 2026-03-29T10:00 — 24 exact hours, different wall clock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both answers are correct; they answer different questions. "Same time tomorrow" is &lt;code&gt;{ days: 1 }&lt;/code&gt;. "Twenty-four hours from now" is &lt;code&gt;{ hours: 24 }&lt;/code&gt;. &lt;code&gt;Date&lt;/code&gt; could not express the difference at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I pass a Temporal object from a Server Component to a Client Component?
&lt;/h2&gt;

&lt;p&gt;No. React rejects class instances at the Server-to-Client boundary with the error &lt;code&gt;Only plain objects, and a few built-ins, can be passed to Client Components from Server Components&lt;/code&gt;. Every Temporal type is a class, so a Temporal value has to be serialized to a string before it becomes a prop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/invoices/page.tsx — Server Component&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;temporal-polyfill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getInvoice&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;due&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlainDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// return &amp;lt;DueBadge due={due} /&amp;gt;;        ✗ Temporal.PlainDate is a class instance&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DueBadge&lt;/span&gt; &lt;span class="na"&gt;due&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ✓ "2026-08-22"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DueBadge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;due&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;due&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlainDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overdue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PlainDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plainDateISO&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;data-overdue&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;overdue&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one surprised me, because React's serializer &lt;em&gt;does&lt;/em&gt; support &lt;code&gt;Date&lt;/code&gt; — it is one of the few built-ins on the allowlist. Swapping &lt;code&gt;Date&lt;/code&gt; for Temporal therefore breaks props that used to work without a word of warning. The consolation is that the string form carries more information than what it replaced: &lt;code&gt;PlainDate.toString()&lt;/code&gt; emits &lt;code&gt;2026-08-22&lt;/code&gt;, and &lt;code&gt;ZonedDateTime.toString()&lt;/code&gt; emits &lt;code&gt;2026-08-22T15:00:00+03:00[Africa/Cairo]&lt;/code&gt;, which round-trips through &lt;code&gt;from()&lt;/code&gt; with the zone intact. An epoch number in a JSON payload can never do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I store Temporal values in Postgres and read them back?
&lt;/h2&gt;

&lt;p&gt;Store an instant in &lt;code&gt;timestamptz&lt;/code&gt;, a calendar date in &lt;code&gt;date&lt;/code&gt;, and the user's IANA time zone in its own &lt;code&gt;text&lt;/code&gt; column whenever the wall-clock intent matters. Postgres has no column type that carries a time zone despite the name — &lt;code&gt;timestamptz&lt;/code&gt; normalizes to UTC on write.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// node-postgres and Drizzle hand back a JS Date for timestamptz — convert at the edge&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEpochMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Rebuild the user's wall clock from instant + stored zone&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toZonedDateTimeISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'Africa/Cairo'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One precision detail caught me out: &lt;code&gt;Temporal.Instant&lt;/code&gt; keeps nanoseconds, Postgres &lt;code&gt;timestamptz&lt;/code&gt; keeps microseconds, and JS &lt;code&gt;Date&lt;/code&gt; keeps milliseconds. A value that travels Temporal → &lt;code&gt;Date&lt;/code&gt; → Postgres → Temporal is not always the value you started with. If exact round-trips matter to a test, round before writing with &lt;code&gt;instant.round({ smallestUnit: 'microsecond' })&lt;/code&gt; so the truncation is a decision you made rather than one the driver made for you.&lt;/p&gt;

&lt;p&gt;For anything scheduled in the future — "send this reminder at 9am local, every week" — store a &lt;code&gt;PlainDateTime&lt;/code&gt; plus the IANA zone rather than an instant. A stored instant freezes today's UTC offset, so the reminder silently shifts by an hour the next time that zone's DST rules change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I delete date-fns, Day.js, or Luxon now?
&lt;/h2&gt;

&lt;p&gt;Not in one commit. Temporal covers what those libraries exist to cover, but the honest comparison is narrower than "Temporal wins".&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;date-fns&lt;/strong&gt; — replaceable for arithmetic and comparison. Its tree-shaken functions all operate on &lt;code&gt;Date&lt;/code&gt;, so mixed code paths need adapters for the length of the migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day.js and Moment&lt;/strong&gt; — replaceable outright. Both wrap &lt;code&gt;Date&lt;/code&gt; and inherit its time-zone model; Day.js needs its &lt;code&gt;timezone&lt;/code&gt; plugin to do what &lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt; does natively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Luxon&lt;/strong&gt; — the closest in spirit, since Luxon and the Temporal proposal share an author and an immutable, zone-aware model. Migration is mostly renaming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intl.DateTimeFormat&lt;/strong&gt; — keep it. Temporal deliberately does not format for humans; it hands off to &lt;code&gt;Intl&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration order that worked for me was to convert at the edges first: parse every inbound string into a Temporal type at the API or form boundary, format back to a string at the render or database boundary, and let the middle of the app stop touching &lt;code&gt;Date&lt;/code&gt; entirely. A Zod schema is a good place to put that parse — &lt;code&gt;z.string().transform((s) =&amp;gt; Temporal.PlainDate.from(s))&lt;/code&gt; gives you a validated Temporal value and a real error message when the string is malformed.&lt;/p&gt;

&lt;p&gt;On shipping it: Temporal is a TC39 Stage 3 proposal and browsers have started enabling it, with Firefox shipping it unflagged first and Safari following, while Chromium and Node are still catching up. Because coverage is uneven, install &lt;code&gt;temporal-polyfill&lt;/code&gt; (the compact implementation) or &lt;code&gt;@js-temporal/polyfill&lt;/code&gt; (the reference one), import it from one shared module, and look at your own bundle analyzer before assuming the weight is free.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Is Temporal a drop-in replacement for Date?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Temporal adds a separate global namespace and does not change &lt;code&gt;Date&lt;/code&gt; at all. Existing code keeps working, and you convert between the two with &lt;code&gt;Temporal.Instant.fromEpochMilliseconds(date.getTime())&lt;/code&gt; and &lt;code&gt;new Date(instant.epochMilliseconds)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why can't I compare two Temporal values with === or &amp;lt;?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Temporal values are objects, so &lt;code&gt;===&lt;/code&gt; compares references rather than calendar values. Use &lt;code&gt;a.equals(b)&lt;/code&gt; for equality and the static comparator &lt;code&gt;Temporal.PlainDate.compare(a, b)&lt;/code&gt;, which returns -1, 0, or 1 and can be passed straight to &lt;code&gt;Array.prototype.sort&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does Temporal handle daylight saving time correctly?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt; does, using the IANA time zone database. When a local time is ambiguous or does not exist because of a DST shift, &lt;code&gt;from()&lt;/code&gt; accepts a &lt;code&gt;disambiguation&lt;/code&gt; option of &lt;code&gt;'compatible'&lt;/code&gt;, &lt;code&gt;'earlier'&lt;/code&gt;, &lt;code&gt;'later'&lt;/code&gt;, or &lt;code&gt;'reject'&lt;/code&gt;, so the behaviour is a decision instead of an accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I use Temporal in Node.js and inside Server Components?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, through a polyfill. Import &lt;code&gt;temporal-polyfill&lt;/code&gt; in server code exactly as you would in the browser — Temporal works fine inside Server Components, Route Handlers, and Server Actions, and only the props boundary needs strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Should I store an instant or a wall-clock time in the database?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Store an instant in &lt;code&gt;timestamptz&lt;/code&gt; for things that already happened, such as &lt;code&gt;created_at&lt;/code&gt;. Store a &lt;code&gt;PlainDateTime&lt;/code&gt; plus an IANA zone for things scheduled in the future, so a change to that zone's DST rules moves the event along with the user's clock.&lt;/p&gt;




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

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>Parallel Routes and Intercepting Routes in the Next.js App Router: Field Notes on the Photo-Modal Pattern</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Tue, 01 Sep 2026 06:00:24 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/parallel-routes-and-intercepting-routes-in-the-nextjs-app-router-field-notes-on-the-photo-modal-56ko</link>
      <guid>https://dev.to/ahmed_mahmoud360/parallel-routes-and-intercepting-routes-in-the-nextjs-app-router-field-notes-on-the-photo-modal-56ko</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Parallel routes let a Next.js App Router layout render more than one page at the same URL through named &lt;code&gt;@slot&lt;/code&gt; folders, and intercepting routes — the &lt;code&gt;(.)&lt;/code&gt;, &lt;code&gt;(..)&lt;/code&gt;, &lt;code&gt;(..)(..)&lt;/code&gt;, and &lt;code&gt;(...)&lt;/code&gt; folder conventions — let a link swap in a different component for that slot without changing the URL. Together they're how an Instagram-style photo modal stays shareable: the same URL renders a modal on a soft navigation and a full page on a hard refresh, and &lt;code&gt;default.tsx&lt;/code&gt; is the seam between the two.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;A parallel route is a named slot — a folder prefixed with &lt;code&gt;@&lt;/code&gt;, like &lt;code&gt;@modal&lt;/code&gt; — that Next.js renders as a prop into the nearest &lt;code&gt;layout.tsx&lt;/code&gt;, alongside the implicit &lt;code&gt;children&lt;/code&gt; slot. Slot folders don't add a segment to the URL.&lt;/li&gt;
&lt;li&gt;Intercepting route conventions — &lt;code&gt;(.)&lt;/code&gt;, &lt;code&gt;(..)&lt;/code&gt;, &lt;code&gt;(..)(..)&lt;/code&gt;, &lt;code&gt;(...)&lt;/code&gt; — match a route relative to the file system, not the URL, so a link clicked inside the feed can render &lt;code&gt;/photo/[id]&lt;/code&gt; as a modal without any folder named &lt;code&gt;(.)photo&lt;/code&gt; ever appearing in the URL.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;default.tsx&lt;/code&gt; is what Next.js renders for a slot when the current URL doesn't match anything inside that slot. Skip it and a hard navigation to a route that doesn't fill every slot 404s.&lt;/li&gt;
&lt;li&gt;A hard refresh or a shared link to &lt;code&gt;/photo/123&lt;/code&gt; is not supposed to show the modal — it's supposed to render &lt;code&gt;app/photo/[id]/page.tsx&lt;/code&gt; as an ordinary full page. That fallback is the entire point of the pattern, not a bug to route around.&lt;/li&gt;
&lt;li&gt;Each slot is its own subtree with its own &lt;code&gt;loading.tsx&lt;/code&gt; and &lt;code&gt;error.tsx&lt;/code&gt;, so a modal can suspend and stream independently of the page rendering behind it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What problem do parallel routes actually solve?
&lt;/h2&gt;

&lt;p&gt;A parallel route renders more than one page in the same layout at the same time, each addressed by a named slot instead of a URL segment. I first reached for this on a photo grid: clicking a thumbnail should open a lightbox without leaving the grid, but the lightbox also needed its own shareable URL so a link to one photo would open directly on a full page. A client-side modal driven by a boolean gets the first half for free and can't do the second half at all — there's no URL for a piece of state that lives in &lt;code&gt;useState&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The folder convention is a name prefixed with &lt;code&gt;@&lt;/code&gt;. Next.js passes each slot's matched content into the nearest layout as a prop named after the folder, alongside the implicit &lt;code&gt;children&lt;/code&gt; slot every layout already receives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  layout.tsx
  page.tsx
  @modal/
    default.tsx
    (.)photo/
      [id]/
        page.tsx
  photo/
    [id]/
      page.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;modal&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here is modal-specific yet — a layout with two slots is just a layout that renders two independent subtrees. I've since used the same mechanism for a dashboard with &lt;code&gt;@team&lt;/code&gt; and &lt;code&gt;@analytics&lt;/code&gt; panes that navigate independently: clicking a link inside &lt;code&gt;@analytics&lt;/code&gt; re-renders only that slot, and &lt;code&gt;@team&lt;/code&gt; keeps whatever it was showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the (.) convention decide what gets intercepted?
&lt;/h2&gt;

&lt;p&gt;The dot-segment conventions match a target route relative to where the intercepting file sits in the file system, not relative to the current URL:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Convention&lt;/th&gt;
&lt;th&gt;Matches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(.)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A route at the same folder level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(..)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A route one folder level above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(..)(..)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A route two folder levels above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A route from the app root&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;app/@modal/(.)photo/[id]/page.tsx&lt;/code&gt; intercepts a client-side navigation to &lt;code&gt;/photo/[id]&lt;/code&gt; when that navigation is triggered from a route sitting at the same level as the &lt;code&gt;@modal&lt;/code&gt; folder. Follow a &lt;code&gt;&amp;lt;Link href="/photo/123"&amp;gt;&lt;/code&gt; from that page and Next.js renders the intercepting component into the &lt;code&gt;@modal&lt;/code&gt; slot instead of swapping out the whole tree. Open &lt;code&gt;/photo/123&lt;/code&gt; as a fresh page load — refresh, paste the URL, click a link from an external site — and Next.js resolves &lt;code&gt;app/photo/[id]/page.tsx&lt;/code&gt; the ordinary way. The interception only fires on a client-side transition; it was never meant to change what a full page load returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does default.tsx exist, and what happens if I skip it?
&lt;/h2&gt;

&lt;p&gt;The first time I skipped it, everything worked in dev until I hit refresh on a route with the modal open and got a 404. A slot without a matching segment for the current URL needs something to render, and on a full page load there's no prior client state to fall back to — Next.js has to render the slot from scratch. &lt;code&gt;default.tsx&lt;/code&gt; is that fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/@modal/default.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client-side navigation is more forgiving: if a slot has no &lt;code&gt;default.tsx&lt;/code&gt; and the new URL doesn't match anything inside it, Next.js keeps rendering whatever that slot last showed rather than unmounting it. That's genuinely useful for the dashboard-tabs case — switching tabs in &lt;code&gt;@analytics&lt;/code&gt; shouldn't reset &lt;code&gt;@team&lt;/code&gt; — but it means a missing &lt;code&gt;default.tsx&lt;/code&gt; can hide itself in every manual click-through test and only surface on a hard reload, which is exactly the path a shared link takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does refreshing the modal route show the full page instead of the modal?
&lt;/h2&gt;

&lt;p&gt;Because it's supposed to. That was the part I fought before I read the pattern correctly: I wanted the modal to somehow survive a refresh, and the actual design is that it shouldn't. &lt;code&gt;app/photo/[id]/page.tsx&lt;/code&gt; is a complete, independent page — same content, no modal chrome, reachable without a single line of client JavaScript executing first. That's what makes the URL genuinely shareable and indexable: a search engine or a link preview bot that doesn't run your client-side router gets the real page, not an empty shell waiting for JavaScript to open a dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I close the modal and get back to where I came from?
&lt;/h2&gt;

&lt;p&gt;The modal component is a Client Component that calls &lt;code&gt;router.back()&lt;/code&gt; from &lt;code&gt;next/navigation&lt;/code&gt;, which reverses the soft navigation that opened it and lets the intercepted slot fall back to its &lt;code&gt;default.tsx&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Modal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"modal-overlay"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;back&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"modal-content"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap I found in production: a visitor who opens &lt;code&gt;/photo/123&lt;/code&gt; directly in a new tab from a shared link has no history entry to go back to, so &lt;code&gt;router.back()&lt;/code&gt; either does nothing or leaves the app. I ended up rendering an explicit close link with a real &lt;code&gt;href&lt;/code&gt; back to the gallery alongside the overlay handler, so closing the modal never depends on history existing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client-state modal vs. intercepting-route modal — what's the actual trade-off?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Boolean-state modal&lt;/th&gt;
&lt;th&gt;Intercepting route modal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own URL&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shareable / shows on refresh&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes — renders as a full page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs client JS to render content at all&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, on direct navigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;useState&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A slot, an intercepting folder, a fallback route, &lt;code&gt;default.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a dialog that's genuinely disposable UI — a confirmation prompt, a settings panel — the boolean is still the right tool. Reach for the routed version specifically when the content behind the modal deserves its own URL.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do parallel route slot folders like &lt;code&gt;@modal&lt;/code&gt; show up in the URL?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. The &lt;code&gt;@&lt;/code&gt; prefix marks a folder as a slot rather than a route segment, so it's invisible to the URL and only affects which prop of the layout receives its content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What happens if someone navigates straight to &lt;code&gt;/photo/123&lt;/code&gt; instead of clicking a thumbnail in the feed?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Next.js renders &lt;code&gt;app/photo/[id]/page.tsx&lt;/code&gt; as a normal full page. The interception only happens on a client-side transition from a matching route, by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can a parallel route slot have its own &lt;code&gt;loading.tsx&lt;/code&gt;?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Each slot is an independent subtree and can define its own &lt;code&gt;loading.tsx&lt;/code&gt;, &lt;code&gt;error.tsx&lt;/code&gt;, and &lt;code&gt;not-found.tsx&lt;/code&gt;, so a modal can suspend on its own data fetch without blocking the page behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why doesn't &lt;code&gt;router.back()&lt;/code&gt; close the modal for someone who opened the link directly?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; There's no history entry to go back to when a route is the first thing loaded in a tab. Pair the close handler with an explicit link to a real fallback route instead of relying on history alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I combine &lt;code&gt;(..)(..)&lt;/code&gt; with a parallel route slot defined several levels up the tree?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes — the dot count is independent of where the &lt;code&gt;@slot&lt;/code&gt; folder lives; it only describes how many folder levels above the intercepting file's own location the target route sits.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Observability in the Next.js App Router: Field Notes on instrumentation.ts, onRequestError, and the Trace That Leaked Across Requests</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/observability-in-the-nextjs-app-router-field-notes-on-instrumentationts-onrequesterror-and-the-3ke3</link>
      <guid>https://dev.to/ahmed_mahmoud360/observability-in-the-nextjs-app-router-field-notes-on-instrumentationts-onrequesterror-and-the-3ke3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Next.js's &lt;code&gt;instrumentation.ts&lt;/code&gt; file and its &lt;code&gt;onRequestError&lt;/code&gt; hook give the App Router real distributed tracing and centralized error reporting, but Vercel Fluid Compute reusing one warm instance across concurrent requests means any request state stored outside OpenTelemetry's own context propagation — a module-level variable, a naive "current request" global — leaks between unrelated users' traces.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;register()&lt;/code&gt;, exported from &lt;code&gt;instrumentation.ts&lt;/code&gt;, runs once when a server instance boots, not once per request — that's exactly where a tracing SDK belongs, and exactly why request-scoped state can't live there.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@vercel/otel&lt;/code&gt;'s &lt;code&gt;registerOTel()&lt;/code&gt; wraps the Node OpenTelemetry SDK with sane defaults for exporters and span processors, cutting most of the boilerplate a manual setup needs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onRequestError&lt;/code&gt;, stable since Next.js 15, catches errors from Server Components, Route Handlers, Server Actions, and Middleware before Next.js renders its own error response — a component-level &lt;code&gt;try/catch&lt;/code&gt; only ever sees its own subtree.&lt;/li&gt;
&lt;li&gt;Fluid Compute reuses one function instance across concurrent requests, so a module-scope "current trace" variable becomes shared mutable state. Only &lt;code&gt;AsyncLocalStorage&lt;/code&gt;-based context — what the OpenTelemetry SDK already uses — keeps data scoped to a single request.&lt;/li&gt;
&lt;li&gt;A trace, a structured log, and a metric answer different questions about the same request. Exporting only one of them leaves gaps the other two would have closed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does instrumentation.ts actually run, and when?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;instrumentation.ts&lt;/code&gt; is a file at the project root — or inside &lt;code&gt;src/&lt;/code&gt; — that Next.js loads before any other application module. Its &lt;code&gt;register()&lt;/code&gt; export runs exactly once, when a new server instance starts up. It does not run per request, per route, or per render. Since Next.js 15 this is stable behavior with no &lt;code&gt;experimental.instrumentationHook&lt;/code&gt; flag required, which trips people who copy setup snippets from Next.js 13 or 14 tutorials.&lt;/p&gt;

&lt;p&gt;That single-run-per-instance timing is the whole reason this file exists: it's the correct place to initialize a tracing SDK, open a long-lived connection, or read one-time configuration, and the wrong place to store anything specific to the request currently in flight.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// instrumentation.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;registerOTel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vercel/otel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Runs once when this server instance boots. Never per-request.&lt;/span&gt;
  &lt;span class="nf"&gt;registerOTel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I wire up OpenTelemetry without hand-rolling the Node SDK?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@vercel/otel&lt;/code&gt; is a package that wraps the standard &lt;code&gt;@opentelemetry/sdk-node&lt;/code&gt; setup — exporter selection, batch span processor configuration, and resource attributes like service name — behind a single &lt;code&gt;registerOTel()&lt;/code&gt; call. Without it, wiring up the Node OpenTelemetry SDK by hand means picking a span processor, a context manager, and an exporter, and getting the instrumentation registration order right before any other module loads.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; to point spans at a collector — Honeycomb, Axiom, Datadog, or a self-hosted OpenTelemetry Collector all accept OTLP. On Vercel specifically, once &lt;code&gt;@vercel/otel&lt;/code&gt; is registered, the platform's own Observability tab picks up the same trace data automatically, which covers a lot of day-to-day debugging without standing up a separate backend.&lt;/p&gt;

&lt;p&gt;The full Node OpenTelemetry SDK needs the Node.js runtime. That's rarely a real constraint now — Fluid Compute makes Node.js the practical default runtime on Vercel anyway, and the older edge runtime was never a good fit for a stateful SDK like this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did spans leak across requests that had nothing to do with each other?
&lt;/h2&gt;

&lt;p&gt;I had a small helper that stashed the current user's ID in a module-level variable, to avoid threading it through five layers of function calls just to attach it to a log line. It worked in local development, where &lt;code&gt;next dev&lt;/code&gt; rarely has more than one request in flight against the same process. In production, under real concurrent traffic, it produced log lines and trace attributes with the wrong user's ID attached — request A's logs carrying request B's identity.&lt;/p&gt;

&lt;p&gt;The cause is Fluid Compute's core behavior: it reuses one warm function instance to serve many concurrent requests instead of spinning up a fresh instance per request. That's good for cold-start latency, and it's exactly why a plain module-level variable is dangerous — it's shared mutable state across every request currently running on that instance, and two requests interleaving on the same instance will stomp on each other's value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Do not do this on Fluid Compute — or any environment that&lt;/span&gt;
&lt;span class="c1"&gt;// reuses one process across concurrent requests.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currentUserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shared across every request on this instance&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is &lt;code&gt;AsyncLocalStorage&lt;/code&gt;, a Node.js primitive that scopes a value to the current async call chain rather than to the module. It's the same mechanism OpenTelemetry's own context manager is built on, and the same reason Next.js's own &lt;code&gt;headers()&lt;/code&gt; and &lt;code&gt;cookies()&lt;/code&gt; functions can be request-scoped without you passing a request object everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AsyncLocalStorage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:async_hooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RequestContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;AsyncLocalStorage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RequestContext&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withRequestContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RequestContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;requestContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;currentUserId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Scoped to this async call chain, not shared across concurrent requests.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;requestContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What does onRequestError catch that a component-level try/catch can't?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;onRequestError&lt;/code&gt; is an optional export from &lt;code&gt;instrumentation.ts&lt;/code&gt;, stable since Next.js 15. Next.js calls it whenever it catches an unhandled error while rendering a Server Component, executing a Route Handler, running a Server Action, or inside Middleware — before it renders its own error boundary or returns a 500 response to the client.&lt;/p&gt;

&lt;p&gt;It receives the error, a request object with &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;method&lt;/code&gt;, and &lt;code&gt;headers&lt;/code&gt;, and a context object with &lt;code&gt;routerKind&lt;/code&gt;, &lt;code&gt;routePath&lt;/code&gt;, and &lt;code&gt;routeType&lt;/code&gt; (&lt;code&gt;render&lt;/code&gt;, &lt;code&gt;route&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, or &lt;code&gt;middleware&lt;/code&gt;). That last piece is what a scattered set of &lt;code&gt;try/catch&lt;/code&gt; blocks never gives you for free: an error boundary inside one component only knows about its own subtree, not which route it belongs to or whether it happened during a render or a Server Action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// instrumentation.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onRequestError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;routerKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pages Router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;App Router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;routePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;routeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;render&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;route&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;middleware&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reportToErrorTracker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routeType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I moved every ad-hoc &lt;code&gt;console.error&lt;/code&gt; I had scattered across route handlers into this one hook. Nothing else in the app decides how errors get reported anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I connect a trace to the log line that explains what happened?
&lt;/h2&gt;

&lt;p&gt;A span ID by itself doesn't help whoever is reading a log at 2 a.m. — the log has to carry the same trace ID as the request that produced it. Vercel's Runtime Logs capture &lt;code&gt;console.log&lt;/code&gt; output automatically, but they don't correlate a log line to a trace unless you attach the IDs yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opentelemetry/api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pino&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pino&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseLogger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestLogger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spanContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSpan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;())?.&lt;/span&gt;&lt;span class="nf"&gt;spanContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;baseLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spanContext&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;traceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spanContext&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;spanId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;trace.getSpan(context.active())&lt;/code&gt; reads the span attached to the currently active OpenTelemetry context — the same context that &lt;code&gt;AsyncLocalStorage&lt;/code&gt; propagates — so this works correctly under concurrent requests for the same reason the fix above does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traces vs structured logs vs metrics — what should I actually export?
&lt;/h2&gt;

&lt;p&gt;Each signal answers a different question, and none of them substitutes for the others.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Answers&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trace&lt;/td&gt;
&lt;td&gt;Where did the time go inside this one request?&lt;/td&gt;
&lt;td&gt;A waterfall across a database call, an external API, and a render — spotting the one slow span&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured log&lt;/td&gt;
&lt;td&gt;What happened, with what data, in this one request?&lt;/td&gt;
&lt;td&gt;Specific error payloads, business events, anything you'd grep for by request ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metric&lt;/td&gt;
&lt;td&gt;How often, across every request?&lt;/td&gt;
&lt;td&gt;Dashboards and alerting thresholds — error rate, p95 latency, request volume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I export all three now, and the trace ID is the thing that ties a spike on a metric dashboard back to the specific log line and the specific span that explains it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need the &lt;code&gt;experimental.instrumentationHook&lt;/code&gt; flag for &lt;code&gt;instrumentation.ts&lt;/code&gt; in Next.js 16?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. The &lt;code&gt;instrumentation.ts&lt;/code&gt; file and its &lt;code&gt;register()&lt;/code&gt; export have been stable since Next.js 15; the experimental flag from Next.js 13 and 14 no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does &lt;code&gt;instrumentation.ts&lt;/code&gt; run on the Edge runtime?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only a subset of it. The full Node OpenTelemetry SDK needs the Node.js runtime, which is the practical default on Vercel now that Fluid Compute makes Node.js the standard choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What's the difference between &lt;code&gt;onRequestError&lt;/code&gt; and a React &lt;code&gt;error.tsx&lt;/code&gt; boundary?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; An &lt;code&gt;error.tsx&lt;/code&gt; boundary catches errors within its own component subtree and renders fallback UI for the user. &lt;code&gt;onRequestError&lt;/code&gt; is a framework-level hook that fires for errors across Server Components, Route Handlers, Server Actions, and Middleware regardless of any particular boundary — it's for reporting, not for rendering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use a module-level variable instead of &lt;code&gt;AsyncLocalStorage&lt;/code&gt; to pass request context around?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only if the instance running your code never serves more than one request at a time. Fluid Compute reuses instances across concurrent requests, so a module-level variable becomes shared mutable state and will leak data between unrelated requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need a separate observability vendor if I'm on Vercel?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Vercel's Observability tab shows traces automatically once &lt;code&gt;@vercel/otel&lt;/code&gt; is registered, which covers a lot of routine debugging. Longer retention, cross-service correlation, or custom alerting usually still means exporting via OTLP to a dedicated backend.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Database Connections in Serverless: Field Notes on Pool Math, PgBouncer Transaction Mode, and the Singleton HMR Kept Recreating</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:00:12 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/database-connections-in-serverless-field-notes-on-pool-math-pgbouncer-transaction-mode-and-the-1bgb</link>
      <guid>https://dev.to/ahmed_mahmoud360/database-connections-in-serverless-field-notes-on-pool-math-pgbouncer-transaction-mode-and-the-1bgb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A database connection pool in a serverless deployment is per-instance, not per-application. The real ceiling is concurrent instances multiplied by pool size, which is why the fix is almost never a bigger database — it is a connection pooler in transaction mode plus a driver configured to survive one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I hit &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; on a Next.js app whose traffic would not have troubled a single Postgres box in 2005. The database was idle. The application code was fine. What was not fine was my mental model: I had configured one pool with a sensible &lt;code&gt;max&lt;/code&gt; and assumed that number described the deployment. It described one function instance.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A connection pool is a cache of open TCP connections living inside one Node.js process. Total connections opened by a serverless app are roughly concurrent instances multiplied by each pool's &lt;code&gt;max&lt;/code&gt;, not the &lt;code&gt;max&lt;/code&gt; you configured.&lt;/li&gt;
&lt;li&gt;PostgreSQL ships with &lt;code&gt;max_connections = 100&lt;/code&gt; by default and reserves three for superusers, and every connection is a separate backend process with its own memory.&lt;/li&gt;
&lt;li&gt;PgBouncer in &lt;code&gt;transaction&lt;/code&gt; mode multiplexes many clients onto few server connections, but it discards session state: prepared statements, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, session-level &lt;code&gt;SET&lt;/code&gt;, and advisory locks held between transactions all break.&lt;/li&gt;
&lt;li&gt;Drizzle with &lt;code&gt;postgres.js&lt;/code&gt; needs &lt;code&gt;prepare: false&lt;/code&gt; behind a transaction-mode pooler; Prisma needs &lt;code&gt;?pgbouncer=true&lt;/code&gt; plus a separate &lt;code&gt;directUrl&lt;/code&gt; for migrations.&lt;/li&gt;
&lt;li&gt;Vercel Fluid Compute reuses one instance across concurrent requests, so &lt;code&gt;max: 1&lt;/code&gt; now serializes handlers instead of protecting the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does my serverless app run out of Postgres connections?
&lt;/h2&gt;

&lt;p&gt;Because each function instance runs its own Node.js process holding its own pool, so the connections you actually open are concurrent instances multiplied by each pool's &lt;code&gt;max&lt;/code&gt;. Nothing about a pool is shared between processes. Ten instances configured with &lt;code&gt;max: 10&lt;/code&gt; is one hundred connections, not ten.&lt;/p&gt;

&lt;p&gt;That product collides with a hard server-side limit. PostgreSQL defaults to &lt;code&gt;max_connections = 100&lt;/code&gt;, holds three back for superuser access, and spawns a separate backend process per connection. The failure is load-shaped: a traffic spike opens more instances, each opens its own pool, and Postgres answers with &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; while database CPU stays flat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did Fluid Compute change about connection pooling?
&lt;/h2&gt;

&lt;p&gt;Fluid Compute reuses a single function instance across concurrent requests, so one module-scope pool is shared by several in-flight requests inside the same process. The classic advice — set &lt;code&gt;max: 1&lt;/code&gt;, because an instance only ever handles one request — is actively harmful under that model: a pool of one serializes concurrent handlers behind a single connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/db.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Module scope: created once per instance, reused by every request&lt;/span&gt;
&lt;span class="c1"&gt;// that instance serves, including concurrent ones under Fluid Compute.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;// per instance, not per deployment&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// hand connections back to the pooler&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which PgBouncer pooling mode should I use?
&lt;/h2&gt;

&lt;p&gt;Transaction mode, for ordinary application traffic. PgBouncer is a lightweight proxy that multiplexes many client connections onto a small set of real server connections, and &lt;code&gt;pool_mode&lt;/code&gt; decides how long a client keeps one of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Server connection held for&lt;/th&gt;
&lt;th&gt;What it breaks&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The whole client session&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Migrations, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, long-lived workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One transaction&lt;/td&gt;
&lt;td&gt;Prepared statements, session &lt;code&gt;SET&lt;/code&gt;, advisory locks outside a transaction, &lt;code&gt;WITH HOLD&lt;/code&gt; cursors&lt;/td&gt;
&lt;td&gt;Serverless application traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;statement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One statement&lt;/td&gt;
&lt;td&gt;Everything above, plus multi-statement transactions&lt;/td&gt;
&lt;td&gt;Rare; sharded setups without transactions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Supabase now fronts Postgres with Supavisor rather than PgBouncer, and Neon and Amazon RDS Proxy ship their own implementations, but the mode semantics are identical everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my prepared statements break behind the pooler?
&lt;/h2&gt;

&lt;p&gt;Because a protocol-level prepared statement is session state on one server connection, and transaction mode may hand you a different connection for the next transaction. The symptom is a pair of errors alternating under load: &lt;code&gt;prepared statement "s1" already exists&lt;/code&gt; and &lt;code&gt;prepared statement "s1" does not exist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PgBouncer 1.21 and later can track prepared statements in transaction mode when &lt;code&gt;max_prepared_statements&lt;/code&gt; is above zero — confirm your provider enables it before depending on it. Otherwise disable them in the driver. &lt;code&gt;node-postgres&lt;/code&gt; only uses named prepared statements when you explicitly name a query; &lt;code&gt;postgres.js&lt;/code&gt; prepares by default and needs &lt;code&gt;prepare: false&lt;/code&gt;; Prisma needs &lt;code&gt;?pgbouncer=true&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// postgres.js + Drizzle behind a transaction-mode pooler&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;drizzle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drizzle-orm/postgres-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// named statements do not survive transaction mode&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I stop dev HMR from opening a new pool on every save?
&lt;/h2&gt;

&lt;p&gt;Cache the pool on &lt;code&gt;globalThis&lt;/code&gt; in development. Next.js hot module replacement re-evaluates changed modules, so &lt;code&gt;new Pool()&lt;/code&gt; at module scope runs again on every save while the previous pool keeps its sockets open. Twenty saves is twenty live pools, and eventually the local database refuses connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/db.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;globalForDb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;globalForDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;globalForDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which connection string should migrations use?
&lt;/h2&gt;

&lt;p&gt;The direct, session-mode connection string — never the transaction-mode pooler. Migrations take advisory locks and run DDL that must stay held across statements, and a transaction-mode pooler can route the next statement to a different backend where that lock does not exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")         // pooled, transaction mode
  directUrl = env("DIRECT_DATABASE_URL")  // direct, session mode
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drizzle Kit takes the same split by pointing its config at the direct URL while the runtime client uses the pooled one. The rule generalises: &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, advisory locks used as a distributed mutex, and anything that sets a session variable belongs on the direct connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is an HTTP database driver the better choice?
&lt;/h2&gt;

&lt;p&gt;When your handler runs one self-contained statement and you would rather not manage a TCP pool at all. Neon's &lt;code&gt;@neondatabase/serverless&lt;/code&gt; package exposes &lt;code&gt;neon()&lt;/code&gt;, which sends a single SQL statement over HTTP — no pool to size, no connection to leak. The cost is that HTTP is stateless: no interactive transactions, no session settings. The same package ships a WebSocket-backed &lt;code&gt;Pool&lt;/code&gt; for cases that need real transactions.&lt;/p&gt;

&lt;p&gt;My rule is boring: HTTP driver for read handlers running one query, TCP pool through a transaction-mode pooler for multi-statement transactions, direct connection for migrations and workers.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How large should &lt;code&gt;max&lt;/code&gt; be in a serverless connection pool?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Small — start around three to five per instance, then watch the server-side connection count at peak concurrency. The number that matters is instances × &lt;code&gt;max&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need PgBouncer if I use Prisma?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Prisma's client-side pool is per-instance like any other and does not coordinate across processes. Add &lt;code&gt;?pgbouncer=true&lt;/code&gt; so Prisma stops relying on named prepared statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does transaction-mode pooling break database transactions?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It pins one server connection for the full duration of a transaction. It breaks state that lives &lt;em&gt;between&lt;/em&gt; transactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why does my local database run out of connections when production does not?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Almost always HMR creating a new pool on every file save. Cache the pool on &lt;code&gt;globalThis&lt;/code&gt; in development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; from a serverless function?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not through a transaction-mode pooler — the listening session is not preserved. Use a direct session connection on a long-lived process, or a real queue.&lt;/p&gt;




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

</description>
      <category>postgres</category>
      <category>serverless</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Environment Variables in Next.js: Field Notes on NEXT_PUBLIC_ Inlining, the Secret That Almost Shipped, and Failing the Build with Zod</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:00:11 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/environment-variables-in-nextjs-field-notes-on-nextpublic-inlining-the-secret-that-almost-3o33</link>
      <guid>https://dev.to/ahmed_mahmoud360/environment-variables-in-nextjs-field-notes-on-nextpublic-inlining-the-secret-that-almost-3o33</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; environment variable is not read at runtime — Next.js replaces every &lt;code&gt;process.env.NEXT_PUBLIC_*&lt;/code&gt; expression with a string literal at build time. That single fact explains the value that refuses to update without a rebuild, the dynamic lookup that returns &lt;code&gt;undefined&lt;/code&gt; in the browser, and why one build artifact cannot serve two environments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An environment variable in Next.js is one name with two different lives. On the server it lives in &lt;code&gt;process.env&lt;/code&gt;, a real Node.js object read at request time. In the browser it does not exist at all: any variable prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is copied into the JavaScript bundle as a literal during &lt;code&gt;next build&lt;/code&gt;, and everything else is stripped. I have watched the same three failures come out of that split on several projects this year — a stale API URL that survived a redeploy, a secret that nearly rode a prop into the HTML, and a missing variable that surfaced as a runtime crash instead of a failed build. These notes are the checklist I now run before the first deploy.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables are inlined into the client bundle at build time. Changing the value in your host's dashboard does nothing until the next build.&lt;/li&gt;
&lt;li&gt;Inlining is static text replacement. &lt;code&gt;process.env[name]&lt;/code&gt; with a dynamic key and &lt;code&gt;const { NEXT_PUBLIC_X } = process.env&lt;/code&gt; both return &lt;code&gt;undefined&lt;/code&gt; in the browser.&lt;/li&gt;
&lt;li&gt;Server-only variables are stripped from client bundles, so &lt;code&gt;process.env.SECRET&lt;/code&gt; in a Client Component is &lt;code&gt;undefined&lt;/code&gt;, not a leak. Secrets leak through props serialized into the RSC payload and through renaming a variable to &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;server-only&lt;/code&gt; package turns "a Client Component imported my secrets module" into a build error instead of a silent risk.&lt;/li&gt;
&lt;li&gt;Validate environment variables with a Zod schema at module load and import that module in &lt;code&gt;next.config.ts&lt;/code&gt;, so a missing variable fails &lt;code&gt;next build&lt;/code&gt; instead of the first production request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why did changing my NEXT_PUBLIC_ variable do nothing?
&lt;/h2&gt;

&lt;p&gt;Because the value the browser sees was compiled into the JavaScript at the last build. During &lt;code&gt;next build&lt;/code&gt;, the bundler performs a find-and-replace: every static &lt;code&gt;process.env.NEXT_PUBLIC_API_URL&lt;/code&gt; expression becomes the string the variable held on the build machine at that moment. The deployed bundle contains the literal URL, not a lookup. Editing the variable in a dashboard, a Docker &lt;code&gt;-e&lt;/code&gt; flag, or &lt;code&gt;.env.production&lt;/code&gt; changes what the &lt;em&gt;next&lt;/em&gt; build will see — the running one is frozen.&lt;/p&gt;

&lt;p&gt;The replacement is textual, which produces a failure mode that looks like a bug in Next.js and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Replaced at build time with a string literal — works in the browser&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// undefined in the browser: inlining is static text replacement,&lt;/span&gt;
&lt;span class="c1"&gt;// and no process.env object exists at runtime to index into&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Also undefined after bundling: destructuring is not a static reference&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server code has none of these restrictions. Inside a Server Component, a route handler, or a Server Action, &lt;code&gt;process.env&lt;/code&gt; is the live Node.js object, read at request time, dynamic keys and all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a server secret actually reach the browser?
&lt;/h2&gt;

&lt;p&gt;Not through &lt;code&gt;process.env&lt;/code&gt; — Next.js strips unprefixed variables from client bundles, so &lt;code&gt;process.env.STRIPE_SECRET_KEY&lt;/code&gt; in a Client Component evaluates to &lt;code&gt;undefined&lt;/code&gt;. The leaks I have actually seen take two other roads. The first is serialization: a Server Component reads a secret and passes it as a prop to a Client Component, and the value is embedded in the RSC payload inside the HTML response, visible in View Source. The second is the "fix" reflex: a developer sees &lt;code&gt;undefined&lt;/code&gt; in the browser, renames &lt;code&gt;API_SECRET&lt;/code&gt; to &lt;code&gt;NEXT_PUBLIC_API_SECRET&lt;/code&gt;, the error disappears, and the secret is now a string literal in a public JavaScript file.&lt;/p&gt;

&lt;p&gt;The cheap defence is the &lt;code&gt;server-only&lt;/code&gt; package — an empty module whose import fails the build if it ends up in the client graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/secrets.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server-only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripeSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any module that touches secrets gets that import at the top. If a Client Component ever imports it, directly or through a chain, the build fails with a readable error instead of shipping. React's experimental taint API (&lt;code&gt;experimental_taintUniqueValue&lt;/code&gt;) covers the prop-serialization road as well, but &lt;code&gt;server-only&lt;/code&gt; is stable today and catches the common case.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fail the build when a variable is missing?
&lt;/h2&gt;

&lt;p&gt;Parse the environment through a Zod schema in a module that runs during the build, and import every variable through it. A missing or malformed variable then stops &lt;code&gt;next build&lt;/code&gt; with a named error instead of surfacing as &lt;code&gt;undefined&lt;/code&gt; in whatever code happened to read it first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/env.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Client vars must be referenced literally so the bundler can inline them&lt;/span&gt;
  &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details earn their place. The keys are listed explicitly instead of passing &lt;code&gt;process.env&lt;/code&gt; wholesale, because client-referenced variables must appear as literal member expressions for the bundler to inline them. And importing this module from &lt;code&gt;next.config.ts&lt;/code&gt; (&lt;code&gt;import './src/env';&lt;/code&gt;) forces the schema to execute at build time even if no route touches it. The &lt;code&gt;@t3-oss/env-nextjs&lt;/code&gt; package wraps the same idea with a server/client split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which .env file wins, and which ones do I commit?
&lt;/h2&gt;

&lt;p&gt;Next.js loads &lt;code&gt;.env&lt;/code&gt; files itself — no &lt;code&gt;dotenv&lt;/code&gt; package needed — with a fixed precedence: an already-set shell variable beats every file, and more specific files beat general ones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Loaded when&lt;/th&gt;
&lt;th&gt;Commit it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every environment&lt;/td&gt;
&lt;td&gt;Yes — shared defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.env.local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every environment except &lt;code&gt;NODE_ENV=test&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No — machine secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env.development&lt;/code&gt; / &lt;code&gt;.env.production&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When &lt;code&gt;NODE_ENV&lt;/code&gt; matches&lt;/td&gt;
&lt;td&gt;Yes — per-env defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env.development.local&lt;/code&gt; / &lt;code&gt;.env.production.local&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When &lt;code&gt;NODE_ENV&lt;/code&gt; matches; beats other files&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;NODE_ENV&lt;/code&gt; itself is not free-form: Next.js recognises exactly &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;, and &lt;code&gt;test&lt;/code&gt;. &lt;code&gt;next dev&lt;/code&gt; forces the first; &lt;code&gt;next build&lt;/code&gt; and &lt;code&gt;next start&lt;/code&gt; force the second. A staging environment is therefore &lt;code&gt;NODE_ENV=production&lt;/code&gt; plus your own variable such as &lt;code&gt;APP_ENV=staging&lt;/code&gt;. One more sharp edge: &lt;code&gt;.env.local&lt;/code&gt; is deliberately ignored when &lt;code&gt;NODE_ENV=test&lt;/code&gt;, so test runs stay reproducible across machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can one build artifact serve both staging and production?
&lt;/h2&gt;

&lt;p&gt;For server-side variables, yes — they are read at request time, so the same Docker image can boot with different &lt;code&gt;DATABASE_URL&lt;/code&gt; values. For &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables, no — their values are already inside the JavaScript. On Vercel this stays invisible because every environment gets its own build with its own variables. A &lt;code&gt;output: 'standalone'&lt;/code&gt; Docker deployment that promotes the same image across environments exposes it immediately.&lt;/p&gt;

&lt;p&gt;Three honest ways out, in the order I try them: keep configuration server-side and let client code call same-origin paths (a rewrite that proxies &lt;code&gt;/api&lt;/code&gt; removes most reasons a browser needs an absolute URL); read the value in a Server Component at request time and pass it down as a prop; or accept one build per environment. What does not work is editing the variable and redeploying the same artifact — and it fails silently, because the old value keeps being served with no error anywhere.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Are my server-only variables exposed to the browser?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Next.js strips variables without the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix from client bundles, so reading one in a Client Component returns &lt;code&gt;undefined&lt;/code&gt;. Exposure happens when a secret is passed as a prop into a Client Component or renamed to a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need the dotenv package in a Next.js project?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Next.js loads &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.local&lt;/code&gt;, and the &lt;code&gt;NODE_ENV&lt;/code&gt;-specific variants itself, in a documented order. Adding &lt;code&gt;dotenv&lt;/code&gt; on top usually just creates a second, conflicting load order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I set NODE_ENV to staging?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Next.js recognises only &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;, and &lt;code&gt;test&lt;/code&gt;, and the CLI commands set it for you. Model staging as &lt;code&gt;NODE_ENV=production&lt;/code&gt; plus your own variable, for example &lt;code&gt;APP_ENV=staging&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why is &lt;code&gt;process.env[name]&lt;/code&gt; undefined in the browser?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Because inlining is a static build-time replacement of literal &lt;code&gt;process.env.NEXT_PUBLIC_*&lt;/code&gt; expressions. There is no &lt;code&gt;process.env&lt;/code&gt; object in the browser to index with a dynamic key, so only the literal form works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I get type-safe environment variables in TypeScript?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Export a Zod-validated &lt;code&gt;env&lt;/code&gt; object from one module and import it everywhere instead of touching &lt;code&gt;process.env&lt;/code&gt; directly. Augmenting the &lt;code&gt;ProcessEnv&lt;/code&gt; type gives autocomplete but no runtime guarantee; the schema gives both.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Webhooks in the Next.js App Router: Field Notes on Raw Bodies, Signature Verification, and Returning 200 Before the Work</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 24 Aug 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/webhooks-in-the-nextjs-app-router-field-notes-on-raw-bodies-signature-verification-and-1bc6</link>
      <guid>https://dev.to/ahmed_mahmoud360/webhooks-in-the-nextjs-app-router-field-notes-on-raw-bodies-signature-verification-and-1bc6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A webhook signature is an HMAC computed over the raw request bytes, so a handler that parses the body before verifying has already destroyed the evidence. In the Next.js App Router, &lt;code&gt;await req.text()&lt;/code&gt; inside a route handler returns those raw bytes — verify first, acknowledge fast, and do the real work idempotently after the response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A webhook is an HTTP POST a provider sends to my server when something happens on their side — a payment settles, a repository receives a push, a subscription cancels. I have wired webhooks into several Next.js App Router apps this year, and every one of them broke in the same three places first: verifying a signature against a body I had already parsed, doing too much work before responding, and processing the same event twice. These notes are the checklist I now start from.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Webhook signatures — Stripe's &lt;code&gt;Stripe-Signature&lt;/code&gt;, GitHub's &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; — are HMACs over the raw request bytes. Verify against &lt;code&gt;await req.text()&lt;/code&gt;, never a re-serialized &lt;code&gt;JSON.parse&lt;/code&gt; result.&lt;/li&gt;
&lt;li&gt;App Router route handlers do not pre-parse request bodies, so the Pages Router &lt;code&gt;bodyParser: false&lt;/code&gt; escape hatch is unnecessary — &lt;code&gt;req.text()&lt;/code&gt; is already the raw payload.&lt;/li&gt;
&lt;li&gt;Return a 2xx quickly. Providers treat a slow response as a failed delivery and retry it, so a slow handler races its own retries.&lt;/li&gt;
&lt;li&gt;Delivery is at-least-once and unordered: dedupe by event ID with a database unique constraint, and fetch the current object from the provider's API instead of trusting payload state.&lt;/li&gt;
&lt;li&gt;Compare signatures with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; and enforce a timestamp tolerance so a captured request cannot be replayed later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does my signature check fail on a genuine request?
&lt;/h2&gt;

&lt;p&gt;Because the signature was computed over the exact bytes the provider sent, and the handler is verifying different bytes. A webhook signature is a keyed hash — an HMAC — of the raw request body, produced with a shared signing secret. If I call &lt;code&gt;JSON.parse&lt;/code&gt; on the body and re-serialize it to verify, key order, whitespace, and unicode escaping can all change, and the HMAC no longer matches even though the request is genuine. The failure is silent and total: every event gets a 400, the provider retries, and the retry queue fills while the code looks correct.&lt;/p&gt;

&lt;p&gt;The App Router makes the correct version easy. A route handler receives the standard web &lt;code&gt;Request&lt;/code&gt; object, and Next.js does not pre-parse it — &lt;code&gt;await req.text()&lt;/code&gt; returns the payload byte-for-byte. The Pages Router needed &lt;code&gt;export const config = { api: { bodyParser: false } }&lt;/code&gt; for the same access; that configuration does nothing in the App Router.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// raw bytes — parse AFTER verification&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the provider ships a verification helper, I use it. &lt;code&gt;stripe.webhooks.constructEvent&lt;/code&gt; checks both the HMAC and the signed timestamp in one call and throws on either failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I verify a webhook signature without an SDK helper?
&lt;/h2&gt;

&lt;p&gt;Compute the HMAC yourself and compare in constant time. Two rules are non-negotiable. First, compare with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;, because an ordinary string comparison returns early at the first differing character and leaks timing information an attacker can use to probe signatures byte by byte. Second, enforce a timestamp tolerance — most providers include a signed timestamp, and rejecting anything older than about five minutes stops a captured request from being replayed later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^sha256=/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// GitHub prefixes the hex digest&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One structural note: a webhook endpoint must be a route handler, not a Server Action. A Server Action is an RPC mechanism for my own application's frontend, addressed by framework-generated identifiers. A webhook needs a stable public POST URL with byte-level body access, and &lt;code&gt;app/api/webhooks/&amp;lt;provider&amp;gt;/route.ts&lt;/code&gt; is exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why should the handler return 200 before doing the real work?
&lt;/h2&gt;

&lt;p&gt;Because the provider treats a slow response as a failed delivery. Delivery timeouts are measured in seconds, and a handler that exceeds one gets marked failed and retried — so the slow handler ends up running concurrently with its own retry. Fulfillment logic that takes ten seconds guarantees every real event arrives at least twice.&lt;/p&gt;

&lt;p&gt;The shape I use now is verify, record, acknowledge, then work. On Vercel, &lt;code&gt;waitUntil&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt; keeps the function instance alive after the response has been sent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;waitUntil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vercel/functions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyAndParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordEventId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// unique constraint = dedupe&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;     &lt;span class="c1"&gt;// runs after the response is sent&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;    &lt;span class="c1"&gt;// acknowledge in milliseconds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the work runs&lt;/th&gt;
&lt;th&gt;Ack speed&lt;/th&gt;
&lt;th&gt;On a crash&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inline, before the response&lt;/td&gt;
&lt;td&gt;Slow — bounded by the work&lt;/td&gt;
&lt;td&gt;Provider retry re-delivers the event&lt;/td&gt;
&lt;td&gt;Trivial work: set a flag, update one row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;waitUntil&lt;/code&gt;, after the response&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Work is lost; event already acknowledged&lt;/td&gt;
&lt;td&gt;Losable side effects: cache warming, notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue or job row, separate worker&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Job survives and is retried&lt;/td&gt;
&lt;td&gt;Money, entitlements, anything you cannot lose&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The queue row is the only option I trust for money. &lt;code&gt;waitUntil&lt;/code&gt; work that dies in a crash was already acknowledged with a 200, and the provider will never resend it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I handle retries and out-of-order events?
&lt;/h2&gt;

&lt;p&gt;By assuming at-least-once delivery and no ordering, because both are documented provider behavior, not edge cases.&lt;/p&gt;

&lt;p&gt;Deduplication: every provider event carries a stable ID — &lt;code&gt;evt_…&lt;/code&gt; on a Stripe event, the &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; header on a GitHub delivery. I insert that ID into a table with a unique constraint before processing; a constraint violation means the event was already handled, so the handler returns 200 and stops. A SELECT-then-INSERT check is a race under concurrent retries — the constraint is the lock.&lt;/p&gt;

&lt;p&gt;Ordering: I do not build state by applying payloads in arrival order. The event is a notification that something changed, not the change itself. For anything stateful I fetch the current object from the provider's API before writing, so a stale payload cannot overwrite newer state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I test webhooks locally?
&lt;/h2&gt;

&lt;p&gt;A provider cannot reach &lt;code&gt;localhost&lt;/code&gt;, so local testing needs a bridge. Three options cover my use: a provider CLI that forwards events — &lt;code&gt;stripe listen --forward-to localhost:3000/api/webhooks/stripe&lt;/code&gt; prints a temporary signing secret for the session; a tunnel such as &lt;code&gt;cloudflared&lt;/code&gt; or &lt;code&gt;ngrok&lt;/code&gt; plus the provider dashboard's manual redelivery button; and signed fixtures in tests.&lt;/p&gt;

&lt;p&gt;The fixtures are the ones that pay rent. I capture one real payload, compute its HMAC with a test secret, and assert three things: the verifier accepts the valid pair, rejects a mutated body, and rejects an expired timestamp. That test catches the raw-body regression — someone adding a JSON middleware or moving the parse above the verify — before it ships and silently 400s every event.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can a Server Action be a webhook endpoint?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. A Server Action is invoked through framework-generated identifiers and is designed for your own application's components. A webhook provider needs a stable public POST URL with raw-body access, which is a route handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need &lt;code&gt;bodyParser: false&lt;/code&gt; in the App Router?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. That option configures Pages Router API routes. An App Router route handler leaves the body untouched until you call &lt;code&gt;req.text()&lt;/code&gt;, &lt;code&gt;req.json()&lt;/code&gt;, or &lt;code&gt;req.formData()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What should I return for event types I do not handle?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Return 200. A non-2xx response tells the provider the delivery failed, so it retries events you will never process, and some providers disable an endpoint that keeps failing. Reserve 400 for signature failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What happens to events sent while my deployment was down?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Providers retry failed deliveries with backoff — Stripe retries for days — so short downtime is usually absorbed. For critical state I also run a periodic reconciliation job against the provider's API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I rotate a webhook secret without downtime?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Verify incoming signatures against both the old and the new secret during the rotation window. Providers like Stripe allow an old signing secret to stay active for an overlap period for exactly this reason.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>webhooks</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>next/image in Next.js 16: Field Notes on LCP, the sizes Prop That Doubles Bandwidth, and What Optimization Costs</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sat, 22 Aug 2026 13:36:14 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/nextimage-in-nextjs-16-field-notes-on-lcp-the-sizes-prop-that-doubles-bandwidth-and-what-3oh5</link>
      <guid>https://dev.to/ahmed_mahmoud360/nextimage-in-nextjs-16-field-notes-on-lcp-the-sizes-prop-that-doubles-bandwidth-and-what-3oh5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Using &lt;code&gt;next/image&lt;/code&gt; is not the same as having fast images. The component lazy-loads every image it renders unless you pass &lt;code&gt;priority&lt;/code&gt;, and a wrong &lt;code&gt;sizes&lt;/code&gt; prop makes a 400-pixel-wide card download the 3840-pixel candidate on a high-DPR screen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; is the Next.js built-in component that generates a responsive &lt;code&gt;srcset&lt;/code&gt;, converts source files to modern formats on demand, and reserves layout space before a byte arrives. I have used it on every Next.js project I have built, and I still spent an afternoon this month working out why a page full of &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; tags had a worse Largest Contentful Paint than the plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; markup it replaced. The layout-shift half is automatic. The bandwidth and latency half is not.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;next/image&lt;/code&gt; lazy-loads every image by default, including the one that is your Largest Contentful Paint element. Only the &lt;code&gt;priority&lt;/code&gt; prop opts an image out of lazy loading and adds a preload hint.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;sizes&lt;/code&gt; prop tells the browser how wide the image will render before CSS is applied. An &lt;code&gt;&amp;lt;Image fill&amp;gt;&lt;/code&gt; with no &lt;code&gt;sizes&lt;/code&gt; is treated as &lt;code&gt;100vw&lt;/code&gt;, which selects the largest candidate in the srcset.&lt;/li&gt;
&lt;li&gt;Next.js 16 removed the &lt;code&gt;images.domains&lt;/code&gt; option, so &lt;code&gt;images.remotePatterns&lt;/code&gt; is the only way to allow a remote host.&lt;/li&gt;
&lt;li&gt;Next.js 16 restricts the &lt;code&gt;quality&lt;/code&gt; prop to values listed in &lt;code&gt;images.qualities&lt;/code&gt;, which defaults to &lt;code&gt;[75]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An image transformation is a unique combination of source image, width, quality and output format, so a wide &lt;code&gt;deviceSizes&lt;/code&gt; array multiplies both cost and cache misses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why is my LCP still slow when I already use next/image?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;next/image&lt;/code&gt; lazy-loads every image by default, including the one that is your Largest Contentful Paint element. Largest Contentful Paint measures when the biggest visible element finishes rendering. A lazy image is not requested until the browser has run layout and decided the image is near the viewport, so the preload scanner — which normally starts image downloads while the HTML is still being parsed — never sees it.&lt;/p&gt;

&lt;p&gt;The fix is one prop, applied to exactly one image per route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Image&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;hero&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/public/hero.jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Hero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
      &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// static import: width, height and blurDataURL come for free&lt;/span&gt;
      &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;          &lt;span class="c1"&gt;// no lazy loading, fetchpriority="high", preload hint in head&lt;/span&gt;
      &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"100vw"&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-full h-auto"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;priority&lt;/code&gt; prop does three things: it removes &lt;code&gt;loading="lazy"&lt;/code&gt;, sets &lt;code&gt;fetchpriority="high"&lt;/code&gt;, and emits a preload link in the document head. Marking six images &lt;code&gt;priority&lt;/code&gt; is the same as marking none, because six high-priority requests then compete for the same connection.&lt;/p&gt;

&lt;p&gt;Two related traps cost me time. First, &lt;code&gt;placeholder="blur"&lt;/code&gt; inlines a base64 data URI into the HTML, so a heavy &lt;code&gt;blurDataURL&lt;/code&gt; grows the document on the critical path. Second, an image rendered by a client component that only mounts after hydration cannot be preloaded at all, whatever you pass to &lt;code&gt;priority&lt;/code&gt; — the markup does not exist when the preload scanner runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the sizes prop actually do, and when does it double my bandwidth?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sizes&lt;/code&gt; attribute tells the browser how wide the image will be rendered, so it can pick a &lt;code&gt;srcset&lt;/code&gt; candidate before stylesheets are applied. Choose the candidate list badly and the browser downloads the biggest file you offered it.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;sizes&lt;/code&gt;, Next.js emits a fixed 1x/2x srcset built from the &lt;code&gt;width&lt;/code&gt; you passed. With &lt;code&gt;sizes&lt;/code&gt;, it emits a full candidate list drawn from &lt;code&gt;images.deviceSizes&lt;/code&gt; (default &lt;code&gt;640, 750, 828, 1080, 1200, 1920, 2048, 3840&lt;/code&gt;) and &lt;code&gt;images.imageSizes&lt;/code&gt; (default &lt;code&gt;16, 32, 48, 64, 96, 128, 256, 384&lt;/code&gt;). The &lt;code&gt;fill&lt;/code&gt; prop with no &lt;code&gt;sizes&lt;/code&gt; is treated as &lt;code&gt;100vw&lt;/code&gt;, so on a 1920-pixel display at device pixel ratio 2 the browser asks for the 3840-pixel file — for a card that renders at 400 pixels.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A product card image that is never wider than 400 CSS pixels.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;fill&lt;/span&gt;
  &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I now apply in review: an &lt;code&gt;&amp;lt;Image fill&amp;gt;&lt;/code&gt; with no &lt;code&gt;sizes&lt;/code&gt; prop is a defect, not a style preference. Verify it in the Chrome DevTools Network panel by comparing the transferred size of each image against the box it renders into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use fill or explicit width and height?
&lt;/h2&gt;

&lt;p&gt;Use explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; whenever you know the intrinsic dimensions, because Next.js turns them into a CSS &lt;code&gt;aspect-ratio&lt;/code&gt; that reserves space and keeps Cumulative Layout Shift at zero. A static import supplies both dimensions and a generated &lt;code&gt;blurDataURL&lt;/code&gt; at build time, so it is the cheapest correct option for any asset in your repository.&lt;/p&gt;

&lt;p&gt;Reach for &lt;code&gt;fill&lt;/code&gt; only when the rendered box is decided by CSS and the source aspect ratio varies — user-uploaded avatars, CMS hero images, a masonry grid. &lt;code&gt;fill&lt;/code&gt; absolutely positions the image, so the parent needs &lt;code&gt;position: relative&lt;/code&gt; and a non-zero height, and cropping is your job through &lt;code&gt;object-fit&lt;/code&gt;. Every &lt;code&gt;fill&lt;/code&gt; image also needs a &lt;code&gt;sizes&lt;/code&gt; prop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for images in Next.js 16?
&lt;/h2&gt;

&lt;p&gt;Next.js 16 tightened image configuration in three ways that break existing config files. The &lt;code&gt;images.domains&lt;/code&gt; option was removed in favour of &lt;code&gt;images.remotePatterns&lt;/code&gt;. The &lt;code&gt;quality&lt;/code&gt; prop is now restricted to values listed in &lt;code&gt;images.qualities&lt;/code&gt;, which defaults to &lt;code&gt;[75]&lt;/code&gt;. And &lt;code&gt;images.localPatterns&lt;/code&gt; lets you restrict which local paths the optimizer will accept, which matters because &lt;code&gt;/_next/image&lt;/code&gt; is a public endpoint that anyone can call with arbitrary parameters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// images.domains was removed in Next.js 16. remotePatterns is the only form.&lt;/span&gt;
    &lt;span class="na"&gt;remotePatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cdn.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/products/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// Only these quality values are accepted. Anything else is rejected.&lt;/span&gt;
    &lt;span class="na"&gt;qualities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;localPatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/avif&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// Set this explicitly. A short TTL recomputes transformations you already paid for.&lt;/span&gt;
    &lt;span class="na"&gt;minimumCacheTTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SVG is still refused by the optimizer unless you set &lt;code&gt;dangerouslyAllowSVG: true&lt;/code&gt;, and that default is correct: an SVG is an executable document, and optimizing one from an untrusted host turns your own origin into the delivery vehicle for its scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should images.formats list AVIF or WebP first?
&lt;/h2&gt;

&lt;p&gt;List AVIF first when bandwidth is the constraint, and WebP first when first-request latency is. The &lt;code&gt;images.formats&lt;/code&gt; array is ordered by preference, and the optimizer picks the first entry the requesting browser accepts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Encode cost&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;Smaller than WebP at equal visual quality&lt;/td&gt;
&lt;td&gt;Noticeably slower to encode&lt;/td&gt;
&lt;td&gt;Images are cached and served many times; bytes dominate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;Larger than AVIF, far smaller than JPEG&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Long-tail images with few hits, where every request is a cache miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG/PNG source&lt;/td&gt;
&lt;td&gt;Largest&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Fallback only, for clients that accept neither&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade-off is uneven across your site. On a marketing page with five hero images that every visitor loads, AVIF encoding happens once and the smaller bytes win forever. On a catalogue with fifty thousand product photos where most are viewed once, the slower encode is on the critical path of a real user every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep image transformation cost bounded?
&lt;/h2&gt;

&lt;p&gt;An image transformation is a unique combination of source image, requested width, quality and output format, and each unique combination is computed and billed once before it is cached. That definition is the whole cost model: everything that multiplies the number of distinct combinations multiplies your bill.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trim &lt;code&gt;deviceSizes&lt;/code&gt;.&lt;/strong&gt; Eight default widths times two formats is sixteen possible transformations per source image. If your layout has three real breakpoints, list three widths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use one quality value.&lt;/strong&gt; Next.js 16 already forces you to declare them; declare one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise &lt;code&gt;minimumCacheTTL&lt;/code&gt;.&lt;/strong&gt; Next.js honours an upstream &lt;code&gt;Cache-Control&lt;/code&gt; max-age when it is longer than &lt;code&gt;minimumCacheTTL&lt;/code&gt;, so a CMS that sends &lt;code&gt;no-store&lt;/code&gt; quietly defeats the cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;unoptimized&lt;/code&gt; on assets that are already optimized&lt;/strong&gt; — sprite sheets, small PNG icons, anything your build pipeline has already squeezed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for cache-busting query strings.&lt;/strong&gt; A source URL with a changing token is a new source image every time, and therefore a new transformation every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When should I bypass next/image entirely?
&lt;/h2&gt;

&lt;p&gt;Three cases justify leaving the component behind. For art direction — a different crop on mobile than desktop — call &lt;code&gt;getImageProps()&lt;/code&gt; (stable since Next.js 15, previously &lt;code&gt;unstable_getImgProps&lt;/code&gt;) to obtain the generated &lt;code&gt;srcset&lt;/code&gt; and feed it into your own &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element or a CSS background. For a static export (&lt;code&gt;output: 'export'&lt;/code&gt;) there is no server to run the optimizer, so you must set &lt;code&gt;images.unoptimized: true&lt;/code&gt; or supply a custom loader. And if you already pay for an image CDN such as Cloudinary or imgix, point &lt;code&gt;images.loaderFile&lt;/code&gt; at it rather than optimizing twice.&lt;/p&gt;

&lt;p&gt;Self-hosting has one more requirement worth stating plainly: the built-in optimizer needs the &lt;code&gt;sharp&lt;/code&gt; package installed. The pure-JavaScript fallback was removed in earlier releases, so a self-hosted deployment without &lt;code&gt;sharp&lt;/code&gt; will fail to optimize rather than silently degrade.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I add priority to every above-the-fold image?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Add &lt;code&gt;priority&lt;/code&gt; to the single image most likely to be the Largest Contentful Paint element. Multiple high-priority images compete for bandwidth and delay the one that actually determines the metric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why does my optimized image look soft on a Retina screen?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Next.js never upscales beyond the intrinsic size of the source file. If the browser requests a 1600-pixel candidate and the source is 800 pixels wide, you get 800 pixels rendered into a 1600-pixel box. Replace the source asset; no configuration fixes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need sharp when self-hosting Next.js?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. The built-in image optimizer requires the &lt;code&gt;sharp&lt;/code&gt; package outside of Vercel, where the platform provides its own optimization layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does next/image work with output: 'export'?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not with the default loader, because a static export has no server. Set &lt;code&gt;images.unoptimized: true&lt;/code&gt; to emit plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags, or configure &lt;code&gt;images.loaderFile&lt;/code&gt; to point at an external image CDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is AVIF always the better choice?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. AVIF produces smaller files than WebP at comparable quality but takes measurably longer to encode, so on images with low cache-hit rates the encode time lands on a real user's first request.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>performance</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>Rate Limiting in the Next.js App Router: Field Notes on Middleware, Redis, and the Server Action That Looks Like Every Other POST</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Wed, 19 Aug 2026 06:00:14 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/rate-limiting-in-the-nextjs-app-router-field-notes-on-middleware-redis-and-the-server-action-2lh2</link>
      <guid>https://dev.to/ahmed_mahmoud360/rate-limiting-in-the-nextjs-app-router-field-notes-on-middleware-redis-and-the-server-action-2lh2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; An in-memory rate limiter is wrong on serverless because every function instance keeps its own counter, so a limit of 10 requests per minute becomes 10 requests per minute &lt;em&gt;per instance&lt;/em&gt;. The second trap is that every Next.js Server Action POSTs to the URL of the page that called it, so path-based limiting in middleware cannot tell one action from another.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A rate limiter is a counter with a deadline: allow N requests per identity per time window and reject the rest with HTTP 429. I have written that in ten lines before and been happy with it. Moving the same idea into a Next.js 16 App Router project on Vercel broke it in three places I did not expect — where the counter lives, who counts as an identity, and how you attach a limit to a Server Action that has no URL of its own.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;An in-memory &lt;code&gt;Map&lt;/code&gt; rate limiter is per-instance on serverless. With ten warm function instances, a 10-requests-per-minute limit permits 100 requests per minute.&lt;/li&gt;
&lt;li&gt;Next.js middleware is the cheapest place to shed abusive traffic because it runs before the route's own function boots, but it also sees RSC prefetch requests that carry the &lt;code&gt;RSC: 1&lt;/code&gt; header and that the user never intentionally made.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NextRequest.ip&lt;/code&gt; was removed in Next.js 15. On Vercel, read the client address with &lt;code&gt;ipAddress(request)&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt; instead of trusting a raw &lt;code&gt;x-forwarded-for&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;Every Next.js Server Action POSTs to the current page URL and carries a build-generated &lt;code&gt;Next-Action&lt;/code&gt; header, so the only reliable place to limit a specific action is inside the action body.&lt;/li&gt;
&lt;li&gt;Reject with status 429 and a &lt;code&gt;Retry-After&lt;/code&gt; header, and decide fail-open versus fail-closed per route &lt;em&gt;before&lt;/em&gt; your Redis has its first outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does an in-memory rate limiter break on serverless?
&lt;/h2&gt;

&lt;p&gt;A module-scope &lt;code&gt;Map&lt;/code&gt; is private to one function instance, and a serverless platform runs many instances at once. Each instance therefore enforces the full limit on its own, so the effective limit is your configured limit multiplied by the number of warm instances. That number is not something you control or can observe from inside the request.&lt;/p&gt;

&lt;p&gt;This is the code I have shipped and regretted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Do not ship this to a serverless runtime.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;windowMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;windowMs&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vercel Fluid Compute makes this harder to notice rather than easier. Fluid Compute reuses a single function instance across concurrent requests instead of spawning one per request, so the &lt;code&gt;Map&lt;/code&gt; survives far longer than it did under classic serverless. In local development and in a quiet preview deployment the limiter looks correct. It only comes apart under traffic spread across enough instances to matter, and nothing in the logs announces it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Map&lt;/code&gt; also never shrinks. On a long-lived instance every unique key you have ever seen stays resident until the instance is recycled, which is a slow memory leak wearing a rate limiter costume.&lt;/p&gt;

&lt;p&gt;The fix is not a cleverer &lt;code&gt;Map&lt;/code&gt;. The counter has to live in a store that every instance shares and that supports an atomic increment: Redis, or any datastore with a compare-and-set primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should the limit run in middleware or in the route handler?
&lt;/h2&gt;

&lt;p&gt;Both, for different jobs. Middleware runs before Next.js resolves the route, so a request rejected there never boots the route's function and never touches your database. That makes middleware the correct place for a coarse, identity-agnostic abuse limit. The route handler knows the authenticated user, the parsed body, and the business meaning of the call, which makes it the correct place for a per-user quota.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ipAddress&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vercel/functions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ratelimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/ratelimit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// RSC prefetches fire on link hover. The user did not ask for these.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rsc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ipAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ip:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate_limited&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RateLimit-Limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RateLimit-Reset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rsc&lt;/code&gt; header check is the line I added after watching my own quota drain while I did nothing but move a mouse. The Next.js router prefetches linked routes on hover and on viewport entry, and those requests are real HTTP requests that hit middleware with an &lt;code&gt;RSC: 1&lt;/code&gt; header. Counting them means a user who scrolls a navigation-heavy page is rate limited before they click anything.&lt;/p&gt;

&lt;p&gt;Since Next.js 15.5 you can also opt middleware into the Node.js runtime with &lt;code&gt;export const config = { runtime: 'nodejs' }&lt;/code&gt;, which lets you use a normal Redis client there. I still prefer an HTTP-based store in middleware, because middleware sits on the latency path of every matched request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placement&lt;/th&gt;
&lt;th&gt;Sees&lt;/th&gt;
&lt;th&gt;Cost of a rejection&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Middleware&lt;/td&gt;
&lt;td&gt;URL, headers, cookies, IP&lt;/td&gt;
&lt;td&gt;Lowest — route function never boots&lt;/td&gt;
&lt;td&gt;IP-level abuse and brute-force shielding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route handler&lt;/td&gt;
&lt;td&gt;Everything, including session and body&lt;/td&gt;
&lt;td&gt;Function has already started&lt;/td&gt;
&lt;td&gt;Per-user quotas, per-endpoint cost control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Action body&lt;/td&gt;
&lt;td&gt;Session, typed arguments&lt;/td&gt;
&lt;td&gt;Function has already started&lt;/td&gt;
&lt;td&gt;Form submissions and mutations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which algorithm should I actually use?
&lt;/h2&gt;

&lt;p&gt;Pick the cheapest algorithm whose failure mode you can live with.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed window&lt;/strong&gt; keeps one counter per window, so a check is a single &lt;code&gt;INCR&lt;/code&gt;. Its flaw is the boundary: ten requests at 11:59:59 and ten more at 12:00:00 pass a "ten per minute" limit while delivering twenty requests in one second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sliding window log&lt;/strong&gt; stores a timestamp per request and is exact, but its memory grows with your traffic, which is the wrong direction for a defence against traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sliding window counter&lt;/strong&gt; weights the previous window by how much of it still overlaps the current one. Bounded memory, no boundary burst, and my default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token bucket&lt;/strong&gt; gives each identity a capacity and a refill rate, so a quiet client may spend saved tokens at once. Right for APIs whose clients legitimately batch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;@upstash/ratelimit&lt;/code&gt; package ships all four as &lt;code&gt;fixedWindow&lt;/code&gt;, &lt;code&gt;slidingWindow&lt;/code&gt;, &lt;code&gt;tokenBucket&lt;/code&gt;, and &lt;code&gt;cachedFixedWindow&lt;/code&gt;. If you write your own against Redis, the thing to get right is atomicity. &lt;code&gt;INCR&lt;/code&gt; followed by a separate &lt;code&gt;EXPIRE&lt;/code&gt; is two round trips, and if the process dies between them you have created a key with no expiry, which locks that identity out permanently. Do it in one script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- One round trip. The TTL is set only on the first hit of a window.&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PEXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I identify the client when everything sits behind a proxy?
&lt;/h2&gt;

&lt;p&gt;A rate limit is only as good as its key. &lt;code&gt;NextRequest.ip&lt;/code&gt; and &lt;code&gt;NextRequest.geo&lt;/code&gt; were removed in Next.js 15, so reading &lt;code&gt;request.ip&lt;/code&gt; is now a type error rather than a subtle wrong answer. On Vercel the replacement is &lt;code&gt;ipAddress(request)&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not key on a raw &lt;code&gt;x-forwarded-for&lt;/code&gt; header unless you are certain a trusted proxy overwrites it. &lt;code&gt;x-forwarded-for&lt;/code&gt; is an ordinary request header, so on any origin reachable directly, an attacker sets it to a new value per request and gets an unlimited number of fresh limit buckets. That is a complete bypass, not a partial failure.&lt;/p&gt;

&lt;p&gt;IPv6 needs its own rule. A single residential subscriber is routinely assigned an entire /64 prefix, so keying on the full 128-bit address hands one attacker 2^64 distinct identities.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;identityKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A stable account beats a network address whenever you have one.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`user:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ipAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// One subscriber can own a whole /64. Key the prefix, not the address.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`ip6:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`ip4:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I rate limit a Server Action when every action is a POST to the same URL?
&lt;/h2&gt;

&lt;p&gt;A Server Action is a function marked with the &lt;code&gt;'use server'&lt;/code&gt; directive that the client invokes over the network. The invocation is an HTTP POST to the URL of the page the action was called from, with a &lt;code&gt;Next-Action&lt;/code&gt; header containing a build-generated identifier for that specific action. There is no dedicated route path, which is exactly what breaks the obvious approach.&lt;/p&gt;

&lt;p&gt;In middleware, a POST from a contact form on &lt;code&gt;/contact&lt;/code&gt; and a POST from a delete-account button on &lt;code&gt;/contact&lt;/code&gt; are the same URL and the same method. You can read &lt;code&gt;request.headers.get('next-action')&lt;/code&gt; to at least distinguish action POSTs from ordinary document requests, but that identifier is a hash that changes when the build changes, so branch on its presence and never on its value.&lt;/p&gt;

&lt;p&gt;The reliable place is inside the action:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ratelimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/ratelimit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`action:sendMessage:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Return a value. A thrown error reaches production as an opaque digest.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Too many messages. Try again in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s.`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ...the real work&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning a value rather than throwing matters. An uncaught error inside a Server Action is redacted in production and surfaces to the client as a generic message with a digest, so the user is told something went wrong instead of being told to wait forty seconds. Rate limiting is a normal outcome, not an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a 429 response actually contain?
&lt;/h2&gt;

&lt;p&gt;HTTP 429 Too Many Requests is the correct status, and &lt;code&gt;Retry-After&lt;/code&gt; is the header that makes a limiter usable by anyone other than a human staring at a browser. Its value is either a number of seconds or an HTTP date.&lt;/p&gt;

&lt;p&gt;Alongside it, emit the limit state. The &lt;code&gt;X-RateLimit-Limit&lt;/code&gt; / &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; / &lt;code&gt;X-RateLimit-Reset&lt;/code&gt; convention is not a standard but it is what most SDKs already parse. The IETF draft &lt;code&gt;draft-ietf-httpapi-ratelimit-headers&lt;/code&gt; defines &lt;code&gt;RateLimit&lt;/code&gt; and &lt;code&gt;RateLimit-Policy&lt;/code&gt; as structured fields; adopt it only if your consumers understand it.&lt;/p&gt;

&lt;p&gt;Two mistakes I have made and seen: returning 403 for a rate limit, which tells the client to stop forever rather than to retry, and returning 500, which pollutes your error rate with your own defences working correctly.&lt;/p&gt;

&lt;p&gt;Then decide what happens when the store is unreachable, because it will be. Failing open on a login endpoint turns a Redis outage into an open brute-force window. Failing closed on a public read endpoint turns a Redis outage into a full outage of your site. I choose per route: fail closed on authentication and on anything that spends money, fail open on reads.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I rate limit in Next.js without Redis?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only if your app runs as a single long-lived process, such as one container instance. On any serverless or autoscaled deployment the counter must live in a store shared across instances, because in-process state is multiplied by your instance count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Next.js middleware run on RSC prefetch requests?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Router prefetches are real HTTP requests that match your middleware &lt;code&gt;matcher&lt;/code&gt; and carry the &lt;code&gt;RSC: 1&lt;/code&gt; header. Exclude them from user-facing quotas or they will consume a visitor's budget before the visitor clicks anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I get the client IP in Next.js 15 and Next.js 16?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;NextRequest.ip&lt;/code&gt; was removed in Next.js 15. On Vercel, call &lt;code&gt;ipAddress(request)&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt;. Elsewhere, read the forwarded header your own trusted proxy sets and confirm that the proxy overwrites rather than appends it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should a rate limiter fail open or fail closed when Redis is down?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Decide per route. Fail closed on login, signup, password reset, and payment endpoints, where failing open creates a security window. Fail open on public reads, where failing closed converts a dependency outage into a site outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is Vercel BotID a replacement for rate limiting?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Vercel BotID is bot detection, which answers whether a caller is automated. A rate limit answers how often any caller, human or not, may perform an expensive operation. They defend different things and compose well together.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>security</category>
      <category>redis</category>
    </item>
  </channel>
</rss>
