<?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: Daniel Pertu</title>
    <description>The latest articles on DEV Community by Daniel Pertu (@daniel_pertu).</description>
    <link>https://dev.to/daniel_pertu</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%2F3981169%2F83f3f6c9-9d75-47a7-9b7c-c28d13594696.jpg</url>
      <title>DEV Community: Daniel Pertu</title>
      <link>https://dev.to/daniel_pertu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daniel_pertu"/>
    <language>en</language>
    <item>
      <title>A preference is not a verdict: adding a third lane without letting it argue with the rules engine</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:46:22 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/a-preference-is-not-a-verdict-adding-a-third-lane-without-letting-it-argue-with-the-rules-engine-3m0a</link>
      <guid>https://dev.to/daniel_pertu/a-preference-is-not-a-verdict-adding-a-third-lane-without-letting-it-argue-with-the-rules-engine-3m0a</guid>
      <description>&lt;p&gt;Munchable scans a barcode and tells you whether a product suits the gut conditions on your profile. People kept asking for something next to that: not "is this safe for my IBS", but "I do not want to buy things with artificial colours in them, tell me when there are some".&lt;/p&gt;

&lt;p&gt;The cheap way to ship that is to add it as another condition. We already have a rules engine that takes a profile of conditions and returns a verdict per condition, so an eighth entry in the enum called "healthy" is maybe an afternoon of work.&lt;/p&gt;

&lt;p&gt;It is also wrong, and the reason is worth more than the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  A preference is not a health claim, and the engine knows the difference
&lt;/h2&gt;

&lt;p&gt;A condition rule set answers a clinical question using clinical guidance. The engine reconciles the per-condition results, detects conflicts between them, and takes the worst tier as the overall verdict, because when two conditions disagree about a product the cautious one has to win.&lt;/p&gt;

&lt;p&gt;Drop a preference into that machinery and it inherits all of it. "Contains an artificial colour" would argue with a low FODMAP assessment as though the two were the same kind of statement, and the worst-tier rule would let a colouring agent out-vote a clinical result. The answer on screen would be a single word covering two questions that have nothing to do with each other.&lt;/p&gt;

&lt;p&gt;So the healthy filter is not a condition. It is a third lane beside the conditions and the allergen layer, and it has three properties none of the others have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It runs only for a profile that switched it on, and is absent from the result entirely for everybody else.&lt;/li&gt;
&lt;li&gt;It never produces a verdict word. The conditions own "Good fit", "Caution" and "Avoid", and this layer is not allowed to use them.&lt;/li&gt;
&lt;li&gt;It never clears a product. It can only say what it found.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the whole design, and it lives in about ten lines of presentation code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never green
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** The row title: the finding count, or the quiet state. */&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;healthTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HealthCheck&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;check&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="nx"&gt;length&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;n&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;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nothing you asked about&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;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 thing you asked about&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; things you asked about`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** The word on the right of the row. Never a verdict word. */&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;healthLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HealthCheck&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;check&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="nx"&gt;length&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;None found&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;Found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Nothing you asked about" rather than "nothing to worry about". The filter reads an ingredient list and whatever nutrition figures the row happens to carry. "Nothing you asked about" is a true sentence. "Nothing to worry about" is a claim about the whole pack that no ingredient list can support.&lt;/p&gt;

&lt;p&gt;The colour follows the same rule: amber when something is found, muted grey when nothing is, and deliberately never green. A green tick beside "none found" reads as approval of the product, and this layer has no opinion about the product. The icon changes too, so the state is never carried by colour alone.&lt;/p&gt;

&lt;p&gt;The tone is different from the allergen rows on purpose. Allergens are safety critical, so they hedge: "not mentioned" must never be read as "free from". The healthy filter does not hedge at all, because the reader set this preference themselves. They asked not to see artificial colours, the pack has one, and naming it is the entire job. Stacking a caveat under a line the reader requested is noise pretending to be diligence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tier is what makes it useful
&lt;/h2&gt;

&lt;p&gt;The obvious implementation of "flag additives" flags everything. Citric acid and lecithin are on tens of thousands of products in our catalogue. A filter that fires on "this is an additive" fires on most of the aisle, and a warning on nearly every product is not a warning, it is wallpaper.&lt;/p&gt;

&lt;p&gt;So each additive carries a concern tier, and only one of the three ever fires:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tier&lt;/th&gt;
&lt;th&gt;behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flagged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;named, when the preference covering its class is on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;notable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;never fires, listed as context under the row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;benign&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;silent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is a second, quieter benefit. A classification is an assessment. Before this existed, every one of those additives showed up on the result screen's "not yet assessed" list and in the curation backlog, for every user, whether or not they had the filter on. "This is citric acid and there is nothing to say about it" is an answer, not a gap.&lt;/p&gt;

&lt;p&gt;You can see that layer working on the public question pages, which are generated by the same engine: &lt;a href="https://munchable.app/does-e330-cause-reflux" rel="noopener noreferrer"&gt;Does E330 cause reflux?&lt;/a&gt; and &lt;a href="https://munchable.app/does-e270-cause-reflux" rel="noopener noreferrer"&gt;Does E270 cause reflux?&lt;/a&gt; are both additives that a naive filter would shout about, and neither page invents a concern. &lt;a href="https://munchable.app/answers" rel="noopener noreferrer"&gt;The full index is here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What automation is allowed to touch
&lt;/h2&gt;

&lt;p&gt;The additive map grows two ways: by hand, and through the same curation pipeline that extends the rest of our taxonomy. The boundary is asymmetric on purpose.&lt;/p&gt;

&lt;p&gt;An automated row may classify an additive, and may file it as &lt;code&gt;notable&lt;/code&gt; or &lt;code&gt;benign&lt;/code&gt;. It may never mark one &lt;code&gt;flagged&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deciding what the filter is for is a product judgement, and a wrong one puts a caution on somebody's shopping. Widening the silent set is a cleanup. Widening the loud set is a claim. So the loud set grows by hand, with a person on it, which is the same rule the rest of the engine follows and which we wrote about in &lt;a href="https://dev.to/daniel_pertu/ai-proposes-the-engine-disposes-five-guards-that-keep-a-model-away-from-the-verdict-5ep3"&gt;AI proposes, the engine disposes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two small structural choices that saved trouble
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One array, not a boolean and a list.&lt;/strong&gt; The on-device profile stores a single array of enabled preferences, and an empty array means the filter is off. A boolean beside a list is two sources of truth for one question, and they will disagree: "on, with nothing selected" is a filter that checks nothing while the settings screen cheerfully says it is working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;Record&lt;/code&gt; over the id type, not a lookup that can miss.&lt;/strong&gt; Every preference the engine knows about must have a card in the app:&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="cm"&gt;/**
 * `Record` over `HealthPreferenceId` is what guarantees every engine preference
 * has a card: leave one out and this file does not compile, which is a better
 * place to find out than a crash on the settings screen.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;META&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="nx"&gt;HealthPreferenceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Omit&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HealthPreferenceMeta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a preference to the engine and forgetting the UI is now a build failure rather than a blank row somebody finds in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the thresholds are somebody else's.&lt;/strong&gt; The nutrient preferences compare against the UK FSA front-of-pack red bands, per 100 g, unchanged. A published, citable standard on purpose: the number a shopper has already seen on the front of thousands of packs is the number this filter should agree with. Inventing our own cut-off would mean disagreeing with the label in their hand, and being right would not help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;If you are bolting a preference system onto something that already produces a judgement, the questions worth asking early are: can this new layer change the existing verdict (ours cannot), can it clear something (ours cannot), and does it use the same vocabulary as the thing that can (ours may not). Three noes, and the two systems can sit on the same screen without either one borrowing the other's authority.&lt;/p&gt;

&lt;p&gt;The public &lt;a href="https://munchable.app/conditions" rel="noopener noreferrer"&gt;conditions pages&lt;/a&gt; show the side of this that does give verdicts, for contrast. The difference in tone between those pages and a preference row is the whole point.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>architecture</category>
      <category>ux</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>We stopped converting our price at checkout and wrote one figure per currency instead</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:44:55 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/we-stopped-converting-our-price-at-checkout-and-wrote-one-figure-per-currency-instead-385p</link>
      <guid>https://dev.to/daniel_pertu/we-stopped-converting-our-price-at-checkout-and-wrote-one-figure-per-currency-instead-385p</guid>
      <description>&lt;p&gt;Munchable is a paid app, and for a while its price was one number in one currency with a conversion bolted on at the end. You read £10 on the pricing page, clicked through, and Stripe's Adaptive Pricing worked out a local figure on its own checkout page using whatever the rate was that morning.&lt;/p&gt;

&lt;p&gt;That arrangement has one obvious defect and one subtle one.&lt;/p&gt;

&lt;p&gt;The obvious one: the number on the page is not the number on the payment screen. That is the moment people abandon a checkout, and being right about the exchange rate does not help you.&lt;/p&gt;

&lt;p&gt;The subtle one: there is no answer to "how much is Munchable in Sweden?". There is only "about this much, today". You cannot put it in an email, an ad, or an app store screenshot, because it was never a price. It was the output of a conversion.&lt;/p&gt;

&lt;p&gt;So the price list became a table, one fixed figure per currency, and both the page and the charge read it.&lt;/p&gt;

&lt;p&gt;You can see it on &lt;a href="https://munchable.app/#pricing" rel="noopener noreferrer"&gt;munchable.app/#pricing&lt;/a&gt;. If you have a VPN, switch country and reload: the figure changes and then stays put, including on the payment screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  One function produces the number, twice
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The price in `currency`, in that currency's minor units, derived from an
 * amount of GBP pence.
 *
 * This is the real price, not a preview of one: the same call feeds the pricing
 * table, the paywall and the Checkout Session. Changing it changes what people
 * are charged.
 */&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;priceIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pence&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="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DisplayCurrency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment is the design. There is no display path and no billing path, there is one function, and the Checkout Session is created for exactly what the landing page printed. A country resolves to a currency, that currency has one price, and that price does not move between the shelf and the till.&lt;/p&gt;

&lt;p&gt;The table is authored in GBP pence and derived once per currency, so adding a market is two edits: a currency definition and the country codes that use it. Everything else falls out of those two maps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounding up is not the same as rounding
&lt;/h2&gt;

&lt;p&gt;An exchange rate produces a number like 12.16. Nobody prices anything at 12.16. Prices are a shape, and the shape is different per currency:&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;ceilToRetail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&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="nx"&gt;digits&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="kr"&gt;number&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;EPSILON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&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;digits&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 100 above a thousand, 10 above a hundred, whole units below that.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;10&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;return&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;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;EPSILON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;step&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;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 0.99 for 2dp, 0.9 for 1dp&lt;/span&gt;
  &lt;span class="k"&gt;return&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;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;EPSILON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;step&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 branches, because currencies are written differently. A currency with decimals goes to the next .99, so 12.16 becomes 12.99. A currency without decimals runs into the thousands, and a yen price of 2,246 reads as the output of a conversion rather than as a price, so it steps to a round figure the way the prices on a shelf there do.&lt;/p&gt;

&lt;p&gt;The epsilon is not decoration. Without it a value that is already exactly on a boundary gets nudged up by a whole step by binary float error, which is how a price quietly gains a hundred yen one deploy.&lt;/p&gt;

&lt;p&gt;The property the rest of the module depends on is that this function never returns less than it was given. We are paid in the customer's currency and settle in sterling, so a conversion fee comes out on the way back, and the published rates drift between refreshes of the table. A small buffer plus the round-up absorb both, so ten pounds of list price stays roughly ten pounds of settled revenue rather than nine sixty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discounts round the other way
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me when writing it. A discount is not a price, and applying the price rounding to it is a bug.&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="cm"&gt;/**
 * The opposite rounding to priceIn, for the opposite reason. A price is
 * rounded up so the figure covers the conversion back to sterling; a discount
 * rounded up is a promise we then fail to keep, so this converts at the plain
 * mid-market rate and rounds DOWN.
 */&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;discountIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pence&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="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DisplayCurrency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A price rounded up costs the customer a few cents and covers our fee. A discount rounded up means we advertised credit we do not honour. The buffer that protects the price is exactly the wrong thing to apply to a reward, so &lt;code&gt;discountIn&lt;/code&gt; drops both the buffer and the round-up and truncates instead. Same table, same rate, opposite direction, because the two numbers are promises to different parties.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unlisted country is the interesting default
&lt;/h2&gt;

&lt;p&gt;Not every country is in the map, and the fallback is the base currency:&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="cm"&gt;/**
 * Any country not listed falls back to GBP, which is both the safe default (it
 * is the currency we settle in) and correct for the UK audience that makes up
 * most of our traffic. An unlisted country therefore reads a pound price and is
 * charged in pounds: the same price twice, which is the promise that matters.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temptation with a fallback is to be clever: guess a currency from the locale, or convert at request time from a rate feed. Both reintroduce exactly the thing we removed, which is a number that exists only for the duration of one request. A visitor in an unlisted country seeing a pound price and paying a pound price has a worse currency experience and a correct one. Adding their country to the map is how that improves, and it is a code change with a review on it rather than an inference.&lt;/p&gt;

&lt;p&gt;The same reasoning, incidentally, is why the rate feed stayed out. The rates are an input to authoring the table, not a runtime dependency: they are sourced from a dated set of reference rates and refreshed deliberately when one moves enough to matter. Rendering a pricing page does not make a network call to find out what to charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero is not a price
&lt;/h2&gt;

&lt;p&gt;One guard worth stealing:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pence&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;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, the round-up turns the free tier's £0 into "€0.99", which advertises a price for the plan whose entire point is that it does not have one. We wrote about that family of bugs separately in &lt;a href="https://dev.to/daniel_pertu/free-is-free-everywhere-and-four-other-rounding-bugs-in-a-price-formatter-bae"&gt;Free is free everywhere, and four other rounding bugs in a price formatter&lt;/a&gt;, and about the trap of a formatter that assumes hundredths in &lt;a href="https://dev.to/daniel_pertu/intl-says-zero-decimals-stripe-wants-hundredths-and-the-shelf-wants-y2300-10ll"&gt;Intl says zero decimals, Stripe wants hundredths, and the shelf wants ¥2,300&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What changed with this one is smaller than any of those and worth more: the price stopped being computed at the moment of payment. It is now a fact about a currency, written down, testable, and quotable in a sentence.&lt;/p&gt;

&lt;p&gt;Go and look: &lt;a href="https://munchable.app/#pricing" rel="noopener noreferrer"&gt;munchable.app/#pricing&lt;/a&gt;. Whatever it says for your country is what the checkout will say, and it will still say it next month.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>payments</category>
    </item>
    <item>
      <title>Our support desk is a catch-all MX record, and the plus address is the thread</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:43:17 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-support-desk-is-a-catch-all-mx-record-and-the-plus-address-is-the-thread-49j2</link>
      <guid>https://dev.to/daniel_pertu/our-support-desk-is-a-catch-all-mx-record-and-the-plus-address-is-the-thread-49j2</guid>
      <description>&lt;p&gt;Munchable is a gut health scanner. People write to us about billing, about a barcode that came back wrong, and about symptoms they are trying to manage. That last category is why we did not buy a helpdesk: a support thread on this product routinely contains a health condition, and posting every one of those conversations into a third party's SaaS is a privacy decision, not a procurement decision.&lt;/p&gt;

&lt;p&gt;So support is a table in our own database with four doors into it. This is what the email half of that turned out to involve, because email is the door that fights back.&lt;/p&gt;

&lt;p&gt;You can walk through the front of it at &lt;a href="https://munchable.app/support" rel="noopener noreferrer"&gt;munchable.app/support&lt;/a&gt;. File something, and you get a reference back. Reply to the email it sends you, and the reply lands on the same thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four doors, one service
&lt;/h2&gt;

&lt;p&gt;A ticket can be created by the web form, by the app, by an inbound email, or by an operator in the admin panel. Every one of those goes through a single module.&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;// apps/web/lib/support/ticket-service.ts&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// The one data-access layer for support_tickets / support_messages. Every door&lt;/span&gt;
&lt;span class="c1"&gt;// into support goes through here, so ownership checks, status transitions and&lt;/span&gt;
&lt;span class="c1"&gt;// ordering are written once rather than four subtly different times.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns DTOs, never rows. That sounds like ceremony until you notice what is on the row and not on the DTO.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread token is the whole trick, and it never leaves the server
&lt;/h2&gt;

&lt;p&gt;When we email you about a ticket, the &lt;code&gt;Reply-To&lt;/code&gt; is not our support address. It is our support address with a token in the plus part:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;threadReplyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;threadToken&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="kr"&gt;string&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;`Munchable Support &amp;lt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SUPPORT_LOCAL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;threadToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SUPPORT_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;threadTokenFromAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9._-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\+([&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]{16,64})&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&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;local&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;SUPPORT_LOCAL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;SUPPORT_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&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;Your mail client keeps that address on every reply in the thread, which is how a message with no subject, sent from a phone, three weeks later, still knows which conversation it belongs to.&lt;/p&gt;

&lt;p&gt;The token is a secret. It is on the ticket row and it is deliberately absent from every DTO the service returns, so no route handler can leak it into a JSON response by accident. Anyone holding it can post into that thread.&lt;/p&gt;

&lt;p&gt;The reference people actually see is a different thing, derived rather than stored:&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="cm"&gt;/** MU-XXXXXX, from the random half of the ticket's ULID. Distinctive in an
 *  email, not a usable handle: reading a ticket still needs the full id. */&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;ticketRef&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="kr"&gt;string&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;`MU-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;
  
  
  The MX record is a catch-all, so the routing is ours
&lt;/h2&gt;

&lt;p&gt;Mail for the whole domain lands on one webhook. What happens next is decided by the recipient: a plus-addressed reply appends to its ticket, mail to the support, bugs and feedback addresses opens a new ticket, and everything else is forwarded on with &lt;code&gt;Reply-To&lt;/code&gt; set to the sender.&lt;/p&gt;

&lt;p&gt;The rule that took an incident to learn: mail that becomes a ticket is not also forwarded. Forwarding it as well puts the same customer in a personal inbox and in the support queue, and then two replies go out, or the same person answers it twice a day apart. The operator gets an alert email instead, and the alert points at the ticket rather than containing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replies arrive with the whole conversation stapled underneath
&lt;/h2&gt;

&lt;p&gt;An email reply contains the reply, then a quoted copy of everything that came before, then possibly a signature. Store that verbatim and every thread grows quadratically, and you re-store your own outbound mail, thread token and all, once per round trip.&lt;/p&gt;

&lt;p&gt;Stripping the quoted tail is a heuristic, so the interesting part is which way it is allowed to be wrong. Cutting too much loses what a customer wrote, which cannot be recovered. Leaving a few quoted lines in is untidy. So the trimming only cuts at unambiguous markers:&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;QUOTE_MARKERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/^On .&lt;/span&gt;&lt;span class="se"&gt;{5,120}\b&lt;/span&gt;&lt;span class="sr"&gt;wrote:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^-&lt;/span&gt;&lt;span class="se"&gt;{2,}\s&lt;/span&gt;&lt;span class="sr"&gt;*Original Message&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*-&lt;/span&gt;&lt;span class="se"&gt;{2,}\s&lt;/span&gt;&lt;span class="sr"&gt;*$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^_&lt;/span&gt;&lt;span class="se"&gt;{5,}\s&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^From:&lt;/span&gt;&lt;span class="se"&gt;\s?&lt;/span&gt;&lt;span class="sr"&gt;.+@.+$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^Sent from my &lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;+/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^-&lt;/span&gt;&lt;span class="se"&gt;{3,}\s&lt;/span&gt;&lt;span class="sr"&gt;*Forwarded message&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*-&lt;/span&gt;&lt;span class="se"&gt;{3,}\s&lt;/span&gt;&lt;span class="sr"&gt;*$/i&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;Three rules around those markers do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A marker in the first line is ignored. A reply that opens by quoting us is quoting us on purpose.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;&amp;gt;&lt;/code&gt; line only starts the quoted tail if the run of quoted lines reaches the bottom of the message. One quoted line in the middle is someone answering inline, and cutting there would delete the rest of what they said.&lt;/li&gt;
&lt;li&gt;If the cut would leave nothing, the original text is kept. An over-eager trim must never turn a real message into an empty one.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;trimmed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two failure modes that are specific to email
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Robots reply too.&lt;/strong&gt; Out-of-office autoresponders, bounce notifications and "we received your message" acknowledgements all arrive looking like a customer. If one of those opens a ticket and the ticket sends a confirmation, and their responder answers the confirmation, you have built a loop with somebody else's mail server. Inbound mail is checked for the headers that mark it automated (&lt;code&gt;Auto-Submitted&lt;/code&gt;, &lt;code&gt;Precedence&lt;/code&gt; and friends) before any of it turns into a conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks retry.&lt;/strong&gt; A delivery that times out halfway is redelivered, and the naive handler appends the same customer message twice. Each event is claimed with a short-lived key before processing and confirmed after, so a retry of an in-flight event is dropped rather than duplicated, and a genuinely failed one is released to be tried again.&lt;/p&gt;

&lt;p&gt;Neither of these is clever. Both are the kind of thing you only write down after seeing it happen once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought
&lt;/h2&gt;

&lt;p&gt;Support conversations live in the same Postgres as everything else, subject to the same deletion rules as the rest of a person's data, which matters when the account deletion endpoint has to actually mean it. There is no seat-priced tool in the loop. And the statuses are ours, so the queue can use the operator's vocabulary while the customer sees theirs:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TICKET_STATUS_LABELS&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="nx"&gt;TicketStatus&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;awaiting_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waiting on you&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Resolved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Closed&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TICKET_STATUS_LABELS_ADMIN&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="nx"&gt;TicketStatus&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Needs a reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;awaiting_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waiting on them&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Resolved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Closed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same four states, two different sentences, one &lt;code&gt;Record&lt;/code&gt; that will not compile if a fifth status appears without both of them being written.&lt;/p&gt;

&lt;p&gt;Try the &lt;a href="https://munchable.app/support" rel="noopener noreferrer"&gt;support form&lt;/a&gt; if you want to see the round trip. The reply that comes back will have a reference on it, and a &lt;code&gt;Reply-To&lt;/code&gt; that is not the address you would guess.&lt;/p&gt;

</description>
      <category>node</category>
      <category>email</category>
      <category>webdev</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Our reels compute their own verdicts, so a video cannot promise what the app would not say</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:38:47 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-reels-compute-their-own-verdicts-so-a-video-cannot-promise-what-the-app-would-not-say-58h</link>
      <guid>https://dev.to/daniel_pertu/our-reels-compute-their-own-verdicts-so-a-video-cannot-promise-what-the-app-would-not-say-58h</guid>
      <description>&lt;p&gt;Munchable is a gut health scanner: you scan a barcode, it tells you whether that product fits the conditions on your profile, and it names the reason. We make short vertical videos for it. Sixteen of them at the moment, each one a phone, a shopping aisle, a scan, and a result card that says "Good fit", "Caution" or "Avoid".&lt;/p&gt;

&lt;p&gt;A video like that is a claim. It is also the only artefact in the whole product that nobody re-renders when the code changes. A screen recording made in March is still sitting on a feed in September, showing a verdict the app stopped giving in June. There is no test that fails, no type error, no alert. The video just quietly becomes a lie.&lt;/p&gt;

&lt;p&gt;So the one rule in our reels package is that no composition may contain a hand-typed verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engine is a dependency of the video
&lt;/h2&gt;

&lt;p&gt;The reels are &lt;a href="https://www.remotion.dev/" rel="noopener noreferrer"&gt;Remotion&lt;/a&gt; compositions: React components rendered to 1080x1920 at 30 fps. That means every reel is an ordinary TypeScript project in the monorepo, and it can import the same package the app imports.&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;// packages/reels/src/verdicts.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;fitCheck&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;@munchable/rules-engine&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;cache&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="nx"&gt;FitCheck&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;assess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProductDef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;FitCheck&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;def&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="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&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;let&lt;/span&gt; &lt;span class="nx"&gt;fit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cache&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;fit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fitCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;engineProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;cache&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="nx"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fit&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;fitCheck&lt;/code&gt; is the production function. It is the same call the scan endpoint makes when a real person points a real phone at a real jar. The composition passes it an illustrated product and a profile, and whatever comes back is what the card draws: the word, the colour, the icon, the named trigger underneath.&lt;/p&gt;

&lt;p&gt;The cache is there because of how Remotion works. A 25 second reel is 750 renders of the same component tree, one per frame, and each of those renders would otherwise re-run the assessment for a product that has not changed. Memoising by product and profile turns 750 evaluations into one.&lt;/p&gt;

&lt;p&gt;The consequence is the point. If a rule changes so that a product in a reel now scores differently, the next render of that reel shows the new verdict. We cannot ship a video that promises something the app would not say, because the video asks the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cast is generic, and the ingredients are real
&lt;/h2&gt;

&lt;p&gt;Every pack in a reel is an invented brand. Real packaging in a paid placement is a trademark problem, so the cast is a set of illustrated cartons, tubs and jars in our own palette, deliberately never green and never red so that nothing on a shelf reads as a verdict before the scan happens.&lt;/p&gt;

&lt;p&gt;The ingredient lists on those invented packs are real. That is what makes the demo honest: the engine is given an ordinary ingredient list of the kind you would find on the back of an ordinary pack, and it works out the answer from there.&lt;/p&gt;

&lt;p&gt;Which introduces the one place drift could still get in. Each illustrated product carries the ingredient ids typed by hand beside the ingredients text, and nothing in the type system stops those ids from being wrong. A typo there does not crash anything. It scores the product against a tag the engine has never heard of, quietly applies the unknown-ingredient penalty a real product would never get, and freezes the reel's verdict away from whatever the real id later comes to mean.&lt;/p&gt;

&lt;p&gt;One test closes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;every reel product is built from ids the engine knows&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;unknown&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;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="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="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="k"&gt;of&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;ingredientsTags&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isKnownTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;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="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&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="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;That is the whole test. It is not clever and it does not need to be. It exists so that "the cast the reels score is the cast the app would score" is a thing CI checks rather than a thing a comment claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  The look came from a comment that promised it mirrored the app
&lt;/h2&gt;

&lt;p&gt;The colours had the same disease. The reels package used to carry its own copy of the app's palette, type scale and verdict vocabulary, under a comment saying it mirrored &lt;code&gt;apps/mobile/src/theme/tokens.ts&lt;/code&gt;. A copy maintained by a comment drifts on the first hurried afternoon. Change the caution colour, or reword the "Can't assess" label in the app, and the marketing videos keep rendering the old one with no type error anywhere in the repo.&lt;/p&gt;

&lt;p&gt;So the platform-free half of the tokens moved into &lt;code&gt;@munchable/design-tokens&lt;/code&gt;: the palette, the spacing and radius scales, the verdict labels and icons. The mobile app re-exports that package and adds what only React Native needs, which is the motion curve, the shadow and the font family names Expo loads. The reels import the same package and add what only a video needs, which is CSS font stacks and pixel line heights. App code still imports everything from one file, and there is now exactly one definition of what "Caution" is called and what colour it is.&lt;/p&gt;

&lt;p&gt;Even the small print comes from the engine. The disclaimer under a result in a reel is the same exported string the app renders, so the sentence people read in a video is the sentence they will read on the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the engine yourself
&lt;/h2&gt;

&lt;p&gt;You do not have to take any of this on trust, because the same engine answers a few hundred public pages.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://munchable.app/does-e330-cause-reflux" rel="noopener noreferrer"&gt;Does E330 cause reflux?&lt;/a&gt; is a page whose answer is produced by running the engine at build time, additive number and all.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://munchable.app/answers" rel="noopener noreferrer"&gt;The full question index&lt;/a&gt; is every one of those pages, and they are generated from the same source of truth as the app's result screen and the reels' result card.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://munchable.app/conditions" rel="noopener noreferrer"&gt;The conditions we cover&lt;/a&gt; is the vocabulary all three surfaces share.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you go through the index and find a page whose reasoning does not match what the app tells you on a scan, that is a bug in one deterministic function rather than a difference of opinion between a marketing team and an engineering team. That is the trade we wanted.&lt;/p&gt;

&lt;p&gt;We wrote earlier about the same idea applied to SEO pages, in &lt;a href="https://dev.to/daniel_pertu/our-generated-seo-pages-run-the-production-engine-and-the-fixture-rotted-3k6e"&gt;Our generated SEO pages run the production engine, and the fixture rotted&lt;/a&gt;. Videos turned out to be the harder case, because a page can be rebuilt on the next deploy and a published video cannot be rebuilt at all.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>video</category>
    </item>
    <item>
      <title>Twenty-eight research dossiers before a line of code, and the rule that every claim carries a URL</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:23:19 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/twenty-eight-research-dossiers-before-a-line-of-code-and-the-rule-that-every-claim-carries-a-url-2oj</link>
      <guid>https://dev.to/daniel_pertu/twenty-eight-research-dossiers-before-a-line-of-code-and-the-rule-that-every-claim-carries-a-url-2oj</guid>
      <description>&lt;p&gt;CogniPrep is a practice platform for the assessments employers use when hiring. Adding support for one of them is mostly not a coding problem. It is a research problem: how long is the real test, is it adaptive, is there negative marking, what does the candidate actually see on screen, what does the employer receive at the end. Get that wrong and you have built a confident simulation of something that does not exist.&lt;/p&gt;

&lt;p&gt;We are in the middle of adding twenty-eight of them, and the process starts with a rule that has nothing to do with code: &lt;strong&gt;phase one writes no code at all.&lt;/strong&gt; One worker per provider, one markdown file each, no commits, no edits to anything else in the repository. The build contract for the round says it in those words, because the instinct when you have read enough to start is to start.&lt;/p&gt;

&lt;p&gt;This post is about the brief those research workers get, because the same brief would work for any domain where an agent or a new team member has to establish facts you cannot personally check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your leads are leads, not facts
&lt;/h2&gt;

&lt;p&gt;Each worker is handed a survey row: provider name, what we think their tests are, which market. The brief then undercuts it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your starting leads are leads, not facts.&lt;/strong&gt; Round one found serious errors in every brief. Verify everything, and where the survey was wrong, say so in a "What the survey got wrong" section near the top.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That section is mandatory and it is the first thing in every dossier. It exists because of what happened without it: a worker who finds that the brief is wrong has a quiet incentive to build what the brief said, since that is the thing that will look correct to whoever reviews it. Making the correction a required section reverses that. A dossier whose first heading is empty now looks suspicious rather than clean.&lt;/p&gt;

&lt;p&gt;One of the round two dossiers opens by explaining that the tests are authored by one organisation but delivered on a different vendor's platform, so the name on the candidate's invitation is never the vendor's. That distinction changes what the page has to be called, what people search for, and which of our existing providers it must not be merged into. It was not in the brief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every factual claim carries the URL you opened
&lt;/h2&gt;

&lt;p&gt;The sourcing rule is the core of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every factual claim about a test (item count, time limit, adaptive or not, negative marking, calculator, item types, interface) carries the URL you opened. Tag each as High (official or vendor), Medium (vendor marketing, reputable secondary) or Low (prep site only). Where sources disagree, record both and say which you would build to and why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three things that rule gets you which a plain "cite your sources" does not:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confidence is per claim, not per document.&lt;/strong&gt; A dossier can be High on the test structure because the organisation publishes it, and Low on item counts because nobody does. The header states both. That is a far more useful artefact than one that reads as uniformly authoritative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disagreement is recorded rather than resolved silently.&lt;/strong&gt; Two prep sites saying different things about a time limit is information. Picking one and deleting the other is how a guess becomes a fact three months later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I opened this URL" is a different claim from "this is on the internet somewhere."&lt;/strong&gt; It is the difference an agent will otherwise blur, and it is exactly the one that matters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dossiers that come back run to a thousand lines or more, with a sources index at the top where each tag resolves to a URL and a date. One worker noted that the official practice site was down for maintenance on the day, so four of the eight interfaces were Medium confidence rather than High. That sentence is worth more than any amount of polish, because it tells the build phase precisely which four screens to treat as assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding nothing is a valid result
&lt;/h2&gt;

&lt;p&gt;The rule I would put in any research brief, in any domain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Employers.&lt;/strong&gt; Only associations backed by a URL you actually opened. No inference from sector norms or peers. Finding none is a valid result.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We publish pages about which employers use which assessment. The temptation to reason "this is a UK retail assessment, and these are the big UK retailers" is enormous, it produces plausible pages instantly, and it is fabrication. Every single one of those associations has to come from a page someone opened, usually the employer's own careers site describing its process.&lt;/p&gt;

&lt;p&gt;"Finding none is a valid result" is the load-bearing sentence. Without it, a researcher who finds nothing has produced what looks like a failure, and the fix for an apparent failure is always to lower the standard of evidence. With it, an empty section is a finding.&lt;/p&gt;

&lt;p&gt;The same applies to scope. One vendor in this round was considered and written off, and it is in the document by name as out of scope, so nobody spends a day rediscovering why.&lt;/p&gt;

&lt;h2&gt;
  
  
  A budget makes people choose better sources
&lt;/h2&gt;

&lt;p&gt;Each research session has a cap of roughly two hundred web searches, and the brief spends it for them: prefer fetching official candidate guides, official PDFs, the vendor's own candidate FAQ and sample pages, over broad searches.&lt;/p&gt;

&lt;p&gt;Constraining the quantity improved the quality, which I did not expect. An unbudgeted researcher searches. A budgeted one goes straight for the document that will answer ten questions at once, which is almost always the primary source. The best dossier in the round leaned on an organisation's own published transparency record for its adaptive algorithm, which no amount of searching around the subject would have matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the research is for
&lt;/h2&gt;

&lt;p&gt;Only then does the build contract get written, and it is written by a reviewer who has read all of the dossiers rather than by each worker for themselves. That is where overlaps get resolved: two providers that turn out to share a platform, two forces in the same country that turn out to sit the same test, a vendor whose battery is reportedly built on another vendor's and which therefore must be scheduled after it rather than beside it.&lt;/p&gt;

&lt;p&gt;None of that is visible in the product. What is visible is that the pages are specific. Vague pages are what you write when you did not find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/games/thomas" rel="noopener noreferrer"&gt;cogniprep.app/games/thomas&lt;/a&gt;: scroll to the FAQ. The answers commit to specifics: whether the test adapts to your answers, whether every education level sits the same version, which older battery the test descends from. Each of those was a sourced line in a dossier before it was a sentence on a page.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/tests" rel="noopener noreferrer"&gt;cogniprep.app/tests&lt;/a&gt;: the "which test am I taking" lookup. Every row exists because someone established what the invitation email actually says, which is a research output, not a design decision.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/cheating/thomas" rel="noopener noreferrer"&gt;cogniprep.app/cheating/thomas&lt;/a&gt;: the same research pointed at a different question. It answers with item counts taken from two published sample reports (178 and 168 items across the battery) rather than with general advice, because that is what was in the sources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are briefing agents to research anything, the four sentences worth stealing are: your leads are not facts, every claim carries the URL you opened, tag the confidence per claim, and finding nothing is a valid result.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>seo</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Next.js traces your imports, so the 102 files we read with fs were not deployed</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:21:39 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/nextjs-traces-your-imports-so-the-102-files-we-read-with-fs-were-not-deployed-1l9h</link>
      <guid>https://dev.to/daniel_pertu/nextjs-traces-your-imports-so-the-102-files-we-read-with-fs-were-not-deployed-1l9h</guid>
      <description>&lt;p&gt;There is a class of bug that only exists in production, produces no build error, and is impossible to reproduce locally no matter how carefully you run the same command. This is one of them, and the cause is a single sentence: &lt;strong&gt;Next.js works out what to deploy by following your imports.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CogniPrep has a dynamic API route that serves content data. The data is a directory of JSON files, about 3.9MB across 102 of them, and the route reads one file per request with &lt;code&gt;fs&lt;/code&gt;. It does not import them.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;next dev&lt;/code&gt; this is perfect. Every file in the repository is on disk, &lt;code&gt;fs&lt;/code&gt; finds whatever it is pointed at, every request works.&lt;/p&gt;

&lt;p&gt;In production every request 404s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the files were not there
&lt;/h2&gt;

&lt;p&gt;When Next.js builds a route for a serverless target, it does not ship your repository. It runs a dependency trace from the route's entry point, follows the &lt;code&gt;import&lt;/code&gt; and &lt;code&gt;require&lt;/code&gt; graph, and copies exactly the files that graph reaches into the function bundle. Anything the trace cannot see does not exist at runtime.&lt;/p&gt;

&lt;p&gt;A string passed to &lt;code&gt;fs.readFile&lt;/code&gt; is not part of an import graph. No bundler can know that a path built at runtime from a request parameter will resolve to a real file, let alone which of 102 it will be. So the route deployed with its code and none of its data, and the first request in production got an &lt;code&gt;ENOENT&lt;/code&gt; that the handler correctly turned into a 404.&lt;/p&gt;

&lt;p&gt;The reason to read from disk rather than import in the first place is cold starts. Importing a directory of JSON means the bundler inlines all 3.9MB into the route's JavaScript, and every cold invocation pays to parse all of it in order to answer a request that needs one file. Reading one file per request keeps the function small and the work proportional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one key, and it should be a glob
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="nx"&gt;outputFileTracingIncludes&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/content/[id]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./lib/content/data/**/*.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that, each of which cost me a try:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key is a route, not a file path.&lt;/strong&gt; It is the route as Next names it internally, dynamic segment in brackets and all. Getting it wrong is silent: you get no warning that the key matched nothing, and the deploy still 404s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The glob is relative to the project root&lt;/strong&gt;, not to the config file or the route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It has to be a glob, not a list.&lt;/strong&gt; This is the part I would argue for even where a list would work today. A new data file that lands next week is included the day it is written, with no config change, and nobody has to know this trap exists to add one. Config that needs updating whenever content is added is config that will be out of date the first time somebody who did not write it adds content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it without deploying
&lt;/h2&gt;

&lt;p&gt;This is the part I wish I had known first, because the loop of "deploy, test, guess" is miserable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next build&lt;/code&gt; writes a trace manifest next to every compiled route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;.next/server/app/api/content/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;/route.js.nft.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is JSON, with a &lt;code&gt;files&lt;/code&gt; array listing every path the tracer decided the route needs. For our route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total traced files: 259
of those, data JSON: 102
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;102 is the whole directory, so the include worked. Without that config key the count is 0, and the other 259 entries, the OpenTelemetry pieces and the rest of the runtime, looked exactly the same. The manifest is where a deploy-time problem becomes a build-time check: grep it for one file you expect, and you know before you push.&lt;/p&gt;

&lt;p&gt;It is also a good way to find the opposite problem. If a route's trace is enormous, something in its import graph is dragging a dependency you did not mean to ship, and &lt;code&gt;outputFileTracingExcludes&lt;/code&gt; is the matching key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule this generalises to
&lt;/h2&gt;

&lt;p&gt;Static analysis sees imports. It does not see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;fs.readFile&lt;/code&gt; with a computed path&lt;/li&gt;
&lt;li&gt;dynamic &lt;code&gt;require()&lt;/code&gt; where the specifier is a variable&lt;/li&gt;
&lt;li&gt;a template, locale file or dataset resolved from a request parameter&lt;/li&gt;
&lt;li&gt;anything fetched by a worker or a child process that the bundler never visited&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these works in development, because development is your whole repository sitting on a disk. Every one of these is a production-only failure, because production is a tarball someone else decided the contents of.&lt;/p&gt;

&lt;p&gt;So when a route reads a file at runtime, the include belongs in the same commit as the read. Not after the first 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/games" rel="noopener noreferrer"&gt;cogniprep.app/games&lt;/a&gt; lists the practice tests. Sign in, start one, and keep the Network tab open: the content for that test arrives in a single request after the page, rather than being baked into the page bundle. That request is the route this whole post is about, and the file it reads was invisible to the build until the config key above existed.&lt;/li&gt;
&lt;li&gt;On your own project: run &lt;code&gt;next build&lt;/code&gt;, then open any &lt;code&gt;.nft.json&lt;/code&gt; under &lt;code&gt;.next/server/app/&lt;/code&gt; and read the &lt;code&gt;files&lt;/code&gt; array. It is the most direct answer available to "what actually gets deployed", and most people never look at it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>node</category>
    </item>
    <item>
      <title>Our preload header downloads four files the page never uses, and one query parameter is why</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:19:57 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-preload-header-downloads-four-files-the-page-never-uses-and-one-query-parameter-is-why-h9</link>
      <guid>https://dev.to/daniel_pertu/our-preload-header-downloads-four-files-the-page-never-uses-and-one-query-parameter-is-why-h9</guid>
      <description>&lt;p&gt;CogniPrep's homepage shows a grid of company logos. The first four are above the fold, so &lt;code&gt;next.config.mjs&lt;/code&gt; sends a preload hint in a response header, which is the earliest possible moment to start those downloads: before the browser has parsed a single byte of HTML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;source&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="nx"&gt;headers&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;value&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;&amp;lt;/logos/hsbc.svg&amp;gt;; rel=preload; as=image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/logos/barclays.svg&amp;gt;; rel=preload; as=image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/logos/lloyds.svg&amp;gt;; rel=preload; as=image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/logos/santander.svg&amp;gt;; rel=preload; as=image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;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="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;That header is live right now. It also downloads four files the page then ignores, and I only found out because I opened the Network tab to take a screenshot for a different post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the waterfall actually says
&lt;/h2&gt;

&lt;p&gt;Homepage, cache disabled, resources filtered to &lt;code&gt;/logos/&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Started&lt;/th&gt;
&lt;th&gt;Transferred&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hsbc.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;77ms&lt;/td&gt;
&lt;td&gt;1,253 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;barclays.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;77ms&lt;/td&gt;
&lt;td&gt;3,186 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lloyds.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;77ms&lt;/td&gt;
&lt;td&gt;3,971 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;santander.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;77ms&lt;/td&gt;
&lt;td&gt;1,207 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hsbc.svg?dpl=dpl_26dq...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;td&gt;1,253 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;barclays.svg?dpl=dpl_26dq...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;td&gt;3,186 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lloyds.svg?dpl=dpl_26dq...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;td&gt;3,971 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;santander.svg?dpl=dpl_26dq...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;83ms&lt;/td&gt;
&lt;td&gt;1,207 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those four logos is fetched twice, six milliseconds apart, with identical byte counts. The preloaded copies are the ones without the query string, and nothing on the page ever asks for them.&lt;/p&gt;

&lt;p&gt;9,617 bytes, on every cold load, at the very front of the queue where they are competing for connections with the resources that actually paint the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query parameter is not ours
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;?dpl=dpl_26dqywkfvSxFuivR2MTB3eRMvsVb&lt;/code&gt; is Vercel's deployment id. The platform appends it to static asset URLs so that a client which has already loaded an older build cannot silently mix its assets with a newer one. It is a good feature. It is also applied to the URLs the framework emits, and not to a &lt;code&gt;Link&lt;/code&gt; header you hand-wrote in &lt;code&gt;next.config.mjs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A preload is matched by URL, and the query string is part of the URL. &lt;code&gt;/logos/hsbc.svg&lt;/code&gt; and &lt;code&gt;/logos/hsbc.svg?dpl=dpl_26dq...&lt;/code&gt; are two different resources as far as the preload cache is concerned. So the browser dutifully fetches the first, then the page asks for the second, and the first sits in the cache until it is evicted, having helped nobody.&lt;/p&gt;

&lt;p&gt;The same trap is waiting behind anything that rewrites asset URLs between your config and your markup: content hashes, a CDN's cache-busting parameter, an image optimiser that serves &lt;code&gt;/_next/image?url=...&lt;/code&gt;, an &lt;code&gt;assetPrefix&lt;/code&gt;. If the preload hint is written by hand and the request is generated by a pipeline, they will drift, and the only symptom is a duplicate row in a waterfall nobody is looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chrome was telling us the whole time
&lt;/h2&gt;

&lt;p&gt;The console on a cold load has this, four times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The resource https://cogniprep.app/logos/hsbc.svg was preloaded using link preload
but not used within a few seconds from the window's load event. Please make sure it
has an appropriate `as` value and it is preloaded intentionally.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is normally read as "you preloaded something you did not need". It also fires in the more annoying case, which is this one: you preloaded something you did need, under a URL the page never requested. Same message, entirely different fix. Worth remembering, because the instinctive response to that warning is to delete the preload, and here the preload was pointing at the right file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The framework was already doing it properly
&lt;/h2&gt;

&lt;p&gt;The grid is a &lt;code&gt;next/image&lt;/code&gt; per logo with the first two rows eager and the first row at high priority:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logo&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;company&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="s2"&gt; logo`&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;loading&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eager&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;lazy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;fetchPriority&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;high&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;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&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;Next.js emits in-document preload links for those eager images, and its links carry the deployment id because the framework is what generates asset URLs in the first place. In the waterfall above, that is what the eight &lt;code&gt;?dpl=&lt;/code&gt; requests at 83ms are: four for the header's logos and four more for the second row, none of them duplicated.&lt;/p&gt;

&lt;p&gt;So the header is not just mismatched, it is redundant. The fix is to delete it and let the eager or priority props on the images do the work, which keeps one system in charge of asset URLs. The alternative, building the header at deploy time from the same deployment id, means teaching a config file about a platform environment variable in order to duplicate a thing the framework already emits correctly. That is a worse trade.&lt;/p&gt;

&lt;p&gt;Six milliseconds of head start is not worth owning a second URL scheme.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it
&lt;/h2&gt;

&lt;p&gt;You can check every claim in this post from outside:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;cogniprep.app&lt;/a&gt; in DevTools, Network tab, &lt;strong&gt;Disable cache&lt;/strong&gt; ticked, then reload and filter for &lt;code&gt;logos&lt;/code&gt;. Count the &lt;code&gt;hsbc.svg&lt;/code&gt; rows. There are two.&lt;/li&gt;
&lt;li&gt;Compare their start times. The bare pair arrives first, ahead of the HTML's own preload links, which is the header doing exactly what it was asked to do with the wrong URL.&lt;/li&gt;
&lt;li&gt;Open the Console on that same load and read the four preload warnings, one per logo.&lt;/li&gt;
&lt;li&gt;Look at the response headers for the document itself. The &lt;code&gt;link:&lt;/code&gt; header is right there, listing the four URLs without the query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the general lesson, which is the only reason this is worth a post: a preload hint is a &lt;strong&gt;string match against a URL&lt;/strong&gt;, not a promise about a file. If any layer of your stack rewrites asset URLs after you write the hint, your preloads are not early, they are extra. The check takes thirty seconds and it is the one where I would not trust the config file.&lt;/p&gt;

</description>
      <category>webperf</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>vercel</category>
    </item>
    <item>
      <title>Nine analytics defaults we turned off, and the one that has to be decided in the browser</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:16:26 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/nine-analytics-defaults-we-turned-off-and-the-one-that-has-to-be-decided-in-the-browser-1l32</link>
      <guid>https://dev.to/daniel_pertu/nine-analytics-defaults-we-turned-off-and-the-one-that-has-to-be-decided-in-the-browser-1l32</guid>
      <description>&lt;p&gt;An analytics SDK arrives with its defaults set to "collect everything". That is the right default for the vendor and almost never the right one for the page, because every option is spending one of three budgets: bytes downloaded, requests made, or rows stored. CogniPrep's &lt;code&gt;posthog.init&lt;/code&gt; call is mostly a list of things switched off, and one decision that is only correct because it is made in the browser rather than in the vendor dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, it is not their domain
&lt;/h2&gt;

&lt;p&gt;The client points at our own origin:&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="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&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="c1"&gt;// Reverse proxy (see `rewrites` in next.config.mjs) so ad blockers do not&lt;/span&gt;
  &lt;span class="c1"&gt;// silently erase a chunk of the numbers.&lt;/span&gt;
  &lt;span class="na"&gt;api_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ingest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ui_host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&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;api_host&lt;/code&gt; is where events go. &lt;code&gt;ui_host&lt;/code&gt; stays the real PostHog host, because that is what the SDK uses when it needs to link a human back to the dashboard. Get that pair the wrong way round and your toolbar links point at your own marketing site.&lt;/p&gt;

&lt;p&gt;The proxy itself is three rewrites, and the comment on them is load bearing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;rewrites&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="c1"&gt;// PostHog reverse proxy - order matters!&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ingest/static/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;destination&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://us-assets.i.posthog.com/static/:path*&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ingest/decide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;destination&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://us.i.posthog.com/decide&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/ingest/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;destination&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://us.i.posthog.com/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js matches rewrites in order and stops at the first hit, so the catch-all has to be last. PostHog serves its static assets from a different host to its ingestion API, &lt;code&gt;us-assets.i.posthog.com&lt;/code&gt; rather than &lt;code&gt;us.i.posthog.com&lt;/code&gt;, so if the generic &lt;code&gt;/ingest/:path*&lt;/code&gt; rule sits first it swallows &lt;code&gt;/ingest/static/...&lt;/code&gt; and sends the library's own script requests to the ingestion endpoint. You do not get an error for that. You get an analytics client that never finishes loading, on some visits, depending on which files it needed.&lt;/p&gt;

&lt;p&gt;This is the whole reason I am wary of "order matters!" comments that do not say which order or why. That one now does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sampling replay on the server samples the wrong thing
&lt;/h2&gt;

&lt;p&gt;The one genuinely interesting decision in the file:&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;SESSION_RECORDING_SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&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;shouldRecord&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;SESSION_RECORDING_SAMPLE_RATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;// `disable_session_recording: true` prevents the recorder chunk from being&lt;/span&gt;
&lt;span class="c1"&gt;// fetched at all, which is why the sampling decision is made above.&lt;/span&gt;
&lt;span class="nx"&gt;disable_session_recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;shouldRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostHog can sample session replay for you in project settings. That setting decides whether a recording is &lt;strong&gt;kept&lt;/strong&gt;. The rrweb recorder is still downloaded, still initialised, and still runs on every visitor whose recording will be thrown away. It is the single largest analytics cost on the page.&lt;/p&gt;

&lt;p&gt;Deciding in the browser, before the library is configured, means nine out of ten sessions never fetch the recorder at all. The recordings stay useful at that rate because of what they are for: watching how a flow actually goes, not auditing one named user. When a specific user's session matters, Sentry's on-error replay already covers it.&lt;/p&gt;

&lt;p&gt;The general shape here is worth stealing even if you never touch PostHog. &lt;strong&gt;A server-side sampling rate is a storage optimisation. A client-side one is a performance optimisation.&lt;/strong&gt; They have the same name and they are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list of noes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Anonymous visitors are counted but get no person record.&lt;/span&gt;
&lt;span class="nx"&gt;person_profiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;identified_only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

&lt;span class="c1"&gt;// Autocapture fired an event on every click and produced `$autocapture` rows&lt;/span&gt;
&lt;span class="c1"&gt;// keyed by DOM position that nobody ever built an insight on.&lt;/span&gt;
&lt;span class="nx"&gt;autocapture&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="nx"&gt;capture_heatmaps&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;// Web vitals are the honest measure of the latency users feel, and cheap.&lt;/span&gt;
&lt;span class="c1"&gt;// Network timing records a payload per request for detail we never needed.&lt;/span&gt;
&lt;span class="nx"&gt;capture_performance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;web_vitals&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="nx"&gt;network_timing&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;// Errors belong in Sentry, which has the stacks, releases and grouping.&lt;/span&gt;
&lt;span class="nx"&gt;capture_exceptions&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;// localStorage only. The default is 'localStorage+cookie'.&lt;/span&gt;
&lt;span class="nx"&gt;persistence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localStorage&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;Each of those has a sentence attached in the source, because "we turned it off" without a reason is how a setting gets turned back on in eighteen months by someone who assumes it was an accident.&lt;/p&gt;

&lt;p&gt;Two are worth expanding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autocapture off costs you heatmaps.&lt;/strong&gt; That is a real trade, not a free win, and it should be written down next to the flag rather than discovered later. The bet is that a small number of named events beats a large number of DOM-position events that nobody queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;persistence: 'localStorage'&lt;/code&gt;&lt;/strong&gt; is not a performance choice, it is a consistency one. The default is &lt;code&gt;localStorage+cookie&lt;/code&gt;, which sets a cookie, and our published cookie policy says analytics identifiers live in local storage. A config default that quietly contradicts your own policy page is the kind of thing that is true for a year before anyone opens DevTools. localStorage alone still recognises a returning visitor, so nothing analytically useful is given up.&lt;/p&gt;

&lt;p&gt;There is one default still on that I have not earned yet: the surveys script loads, and we run no surveys. That one is on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  None of it starts until the page is idle
&lt;/h2&gt;

&lt;p&gt;The loader is an &lt;code&gt;import()&lt;/code&gt; scheduled at the first idle moment, with a Safari fallback, because Safari still has no &lt;code&gt;requestIdleCallback&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="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestIdleCallback&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&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;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;loadPostHog&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4000&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelIdleCallback&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;handle&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;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadPostHog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is lost by starting late. &lt;code&gt;capture_pageview: 'history_change'&lt;/code&gt; records the initial view whenever init happens, and the thin client module queues anything fired before the library lands. The failure path is a &lt;code&gt;.catch&lt;/code&gt; that does nothing on purpose: blocked, offline or a failed chunk all mean the queued events are never sent and the page carries on.&lt;/p&gt;

&lt;p&gt;The component also renders no context provider. &lt;code&gt;posthog-js/react&lt;/code&gt; exists for the &lt;code&gt;usePostHog&lt;/code&gt; and feature flag hooks, this app uses neither, so importing it would be bundle weight in exchange for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;cogniprep.app&lt;/a&gt; with DevTools on the Network tab and filter for &lt;code&gt;ingest&lt;/code&gt;. You will see &lt;code&gt;config.js&lt;/code&gt;, &lt;code&gt;web-vitals-with-attribution.js&lt;/code&gt;, &lt;code&gt;surveys.js&lt;/code&gt; and an event POST, every one of them on &lt;code&gt;cogniprep.app&lt;/code&gt;. Now filter for &lt;code&gt;posthog.com&lt;/code&gt;: nothing. That is the reverse proxy working.&lt;/li&gt;
&lt;li&gt;Note the timings. On my last load the first &lt;code&gt;/ingest&lt;/code&gt; request started about 170ms after navigation, well after the page had painted.&lt;/li&gt;
&lt;li&gt;Application, Local Storage, &lt;code&gt;ph_&amp;lt;project key&amp;gt;_posthog&lt;/code&gt;. Then read &lt;a href="https://cogniprep.app/cookies" rel="noopener noreferrer"&gt;cogniprep.app/cookies&lt;/a&gt;, which describes that same key in the local storage section. The policy and the config are meant to agree, and this is the check that they do.&lt;/li&gt;
&lt;li&gt;Reload a handful of times and watch for a recorder chunk in the Network tab. Most loads will not have one, which is the client-side sample rate you are watching from the outside.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>webperf</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Our PWA plugin never emitted a service worker, and Turbopack is the reason</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:15:03 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-pwa-plugin-never-emitted-a-service-worker-and-turbopack-is-the-reason-3gp0</link>
      <guid>https://dev.to/daniel_pertu/our-pwa-plugin-never-emitted-a-service-worker-and-turbopack-is-the-reason-3gp0</guid>
      <description>&lt;p&gt;CogniPrep's &lt;code&gt;next.config.mjs&lt;/code&gt; used to export its config through a PWA wrapper, &lt;code&gt;@ducanh2912/next-pwa&lt;/code&gt;, configured with two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cacheOnFrontEndNav&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aggressiveFrontEndNavCaching&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both read as "client side navigation is faster now". Neither did anything, and had not for as long as anyone could check.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-pwa&lt;/code&gt; and its forks work by injecting a &lt;strong&gt;webpack&lt;/strong&gt; plugin, which is the thing that runs Workbox and writes &lt;code&gt;sw.js&lt;/code&gt; into your output. &lt;code&gt;next build&lt;/code&gt; on this project compiles with Turbopack, so that plugin was never instantiated. No error. No warning. The build succeeded every time, and the config kept sitting at the top of the file looking like a feature.&lt;/p&gt;

&lt;p&gt;Next.js 16 is where most people will meet this, because Turbopack is the default builder there rather than a flag you opt into. If your project carried a webpack-plugin-shaped dependency across that upgrade, it is worth checking whether it is still doing its job, because the failure mode is silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to prove it is dead rather than assuming
&lt;/h2&gt;

&lt;p&gt;The check that settled it took a minute:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;public/sw.js&lt;/code&gt; does not exist in the working tree.&lt;/li&gt;
&lt;li&gt;No service worker exists in the build output.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git log&lt;/code&gt; has never contained one.&lt;/li&gt;
&lt;li&gt;On the deployed site, &lt;code&gt;https://cogniprep.app/sw.js&lt;/code&gt; returns 404.&lt;/li&gt;
&lt;li&gt;In the browser, &lt;code&gt;await navigator.serviceWorker.getRegistrations()&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point 5 is the one I would start with on any site you have inherited. Paste it into the console of the production page. If the array is empty and your config says you have a PWA, your config is a comment.&lt;/p&gt;

&lt;p&gt;What replaced the wrapper in our repo is a comment explaining the hole, which is the only honest thing to leave behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// REMOVED: @ducanh2912/next-pwa.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// It was configured with `cacheOnFrontEndNav` and `aggressiveFrontEndNavCaching`,&lt;/span&gt;
&lt;span class="c1"&gt;// which read as an attempt to make client-side navigation faster. It never did&lt;/span&gt;
&lt;span class="c1"&gt;// anything. The plugin works by injecting a webpack plugin, and `next build`&lt;/span&gt;
&lt;span class="c1"&gt;// compiles this project with Turbopack, so no service worker was ever emitted.&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Dead configuration that looks like a performance feature is worse than none,&lt;/span&gt;
&lt;span class="c1"&gt;// because it stops anyone from asking why navigation is slow.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead config is worse than no config for exactly that reason. As long as the file says "aggressive navigation caching", nobody profiles navigation. The line was an answer to a question that had never been asked properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half we do ship
&lt;/h2&gt;

&lt;p&gt;What survived is the manifest, which in the App Router is a TypeScript file rather than a static asset:&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="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&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="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;manifest&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Manifest&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CogniPrep - Arctic Shores Practice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;short_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CogniPrep&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Practice all 14 Arctic Shores psychometric games and AI mock video interviews.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;start_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;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;background_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;theme_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;orientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;portrait&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;icons&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;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/logo-light.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;512x512&lt;/span&gt;&lt;span class="dl"&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;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;any&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;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/logo-light.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;512x512&lt;/span&gt;&lt;span class="dl"&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;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;maskable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next serves that at &lt;code&gt;/manifest.webmanifest&lt;/code&gt; and injects the &lt;code&gt;&amp;lt;link rel="manifest"&amp;gt;&lt;/code&gt; tag into every page for you. There is no &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; anywhere in our layout; the file's existence is the wiring.&lt;/p&gt;

&lt;p&gt;That gets you the metadata half of a progressive web app: the name and icon a phone uses when someone saves the page to their home screen, the theme colour the browser paints its chrome with, and the &lt;code&gt;standalone&lt;/code&gt; display mode that hides the URL bar once the app is installed. It gets you none of the offline half, because offline is entirely a service worker concern.&lt;/p&gt;

&lt;p&gt;Being clear about which half you have matters, because a manifest is the part that makes a site &lt;em&gt;look&lt;/em&gt; installable in an audit tool while the behaviour people actually associate with a PWA, working on the train, is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things in that manifest are still wrong
&lt;/h2&gt;

&lt;p&gt;Writing this post is what made me read the object properly, which is the usual outcome of explaining your own config to strangers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both icons are the same file.&lt;/strong&gt; One entry is &lt;code&gt;purpose: 'any'&lt;/code&gt;, the other is &lt;code&gt;purpose: 'maskable'&lt;/code&gt;, and they point at the same 512 square PNG. Maskable icons are not a flag you set on an existing asset. The platform crops them to whatever shape it likes, a circle, a squircle, a rounded square, and only the middle 80% is guaranteed to survive. A maskable entry needs to be a separate export with the mark shrunk inside that safe zone. Declaring it on the standard icon means Android is free to shave the edges off our logo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no 192 pixel icon.&lt;/strong&gt; The conventional set is 192 and 512. Shipping only one size leaves the smaller slots to be produced by downscaling, which is exactly where a detailed mark goes muddy.&lt;/p&gt;

&lt;p&gt;Neither is a crisis, because nobody can install the thing as an offline app today anyway. Both are on the list for when a Turbopack-compatible service worker, Serwist is the usual successor, is actually worth doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/manifest.webmanifest" rel="noopener noreferrer"&gt;cogniprep.app/manifest.webmanifest&lt;/a&gt;: the live output of that TypeScript function. Note the two identical icon entries.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/sw.js" rel="noopener noreferrer"&gt;cogniprep.app/sw.js&lt;/a&gt;: 404, which is the whole post in one request.&lt;/li&gt;
&lt;li&gt;Open &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;cogniprep.app&lt;/a&gt;, then DevTools, Application, Service Workers. The list is empty. Compare with Application, Manifest, which is fully populated. That gap is the shape of a half-built PWA and it is very common.&lt;/li&gt;
&lt;li&gt;View source and search for &lt;code&gt;rel="manifest"&lt;/code&gt;. It is there, and it is not in our source code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I took from it is narrower than "audit your dependencies". It is that a build plugin which no longer runs produces the same successful build as one that does, so migrating bundlers needs an output check per plugin, not a green build.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>webperf</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Our health endpoint is public, so it answers with a count instead of a name</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:06:15 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-health-endpoint-is-public-so-it-answers-with-a-count-instead-of-a-name-3nmk</link>
      <guid>https://dev.to/daniel_pertu/our-health-endpoint-is-public-so-it-answers-with-a-count-instead-of-a-name-3nmk</guid>
      <description>&lt;p&gt;CogniPrep has a health check at &lt;code&gt;/api/health&lt;/code&gt;. It has no authentication, no rate limit and no secret query parameter. That is deliberate: an uptime monitor that needs credentials is a thing you turn off during an incident, which is the exact moment you wanted it.&lt;/p&gt;

&lt;p&gt;Open it right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cogniprep.app/api/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today it says this:&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"healthy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-28T08:04:00.782Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"checks"&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;"database"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"healthy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"responseTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;67&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;"redis"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"configured"&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;"environment"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"healthy"&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;"monitoring"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"configured"&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;Four statuses and one number. That is the whole payload, and the interesting part is everything that is not in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A public endpoint is a reconnaissance surface
&lt;/h2&gt;

&lt;p&gt;The useful debugging information in a health check is exactly the information you would want if you were probing someone else's stack. Two places in this route had to be written twice because the first version was helpful to the wrong audience.&lt;/p&gt;

&lt;p&gt;The first is the database check. A failed query has an error message attached, and postgres driver errors are chatty: host names, role names, pooler internals, sometimes a truncated statement.&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Log the detail for us; return only the status to the caller. Database error&lt;/span&gt;
  &lt;span class="c1"&gt;// messages can disclose host names, roles and driver internals.&lt;/span&gt;
  &lt;span class="nf"&gt;logError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[health] Database check failed&lt;/span&gt;&lt;span class="dl"&gt;'&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;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt; &lt;span class="o"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unhealthy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;isHealthy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detail still exists. It goes to the logger, which goes to Sentry, which is where the person fixing it is looking anyway. The caller gets one word.&lt;/p&gt;

&lt;p&gt;The second is the environment check, and it is the one I would have got wrong by default:&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;requiredEnvVars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DATABASE_URL&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;STRIPE_SECRET_KEY&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;STRIPE_WEBHOOK_SECRET&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;CRON_SECRET&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;missingEnvVars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requiredEnvVars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="c1"&gt;// Report only the count. Naming the missing variables tells an anonymous caller&lt;/span&gt;
&lt;span class="c1"&gt;// exactly which part of our configuration is absent, e.g. that&lt;/span&gt;
&lt;span class="c1"&gt;// STRIPE_WEBHOOK_SECRET is unset, which implies webhook verification is broken.&lt;/span&gt;
&lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="o"&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="nx"&gt;missingEnvVars&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="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;healthy&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;unhealthy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;missingCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;missingEnvVars&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;missingEnvVars&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="kc"&gt;undefined&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;{"missingCount": 1}&lt;/code&gt; tells an operator to go and look at the deploy. &lt;code&gt;{"missing": ["STRIPE_WEBHOOK_SECRET"]}&lt;/code&gt; tells a stranger that our webhook signature verification is currently unenforced, which is an invitation to post a fake &lt;code&gt;checkout.session.completed&lt;/code&gt; at us. The names go to the log line, not the response body.&lt;/p&gt;

&lt;p&gt;Same reasoning for Redis and Sentry: the response says &lt;code&gt;configured&lt;/code&gt; or &lt;code&gt;not_configured&lt;/code&gt;, never a URL, never a project, never a DSN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Degraded is not down
&lt;/h2&gt;

&lt;p&gt;The database check times itself and grades the result:&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;dbResponseTime&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="nx"&gt;dbStart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt; &lt;span class="o"&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="nx"&gt;dbResponseTime&lt;/span&gt; &lt;span class="o"&gt;&amp;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;degraded&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;healthy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;responseTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dbResponseTime&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;degraded&lt;/code&gt; still returns HTTP 200. Only a real failure, a thrown query or a missing required variable, returns 503.&lt;/p&gt;

&lt;p&gt;That split exists because a monitor that pages on slowness gets muted, and a muted monitor does not page on outages either. A one second query on a pooled Postgres connection that has just cold started is not an outage, it is a Monday. The number is published so a human can watch it drift; the status code is reserved for "a thing is actually broken".&lt;/p&gt;

&lt;p&gt;&lt;code&gt;responseTime&lt;/code&gt; is the one piece of internal telemetry the endpoint does hand out. It is a duration with no identifiers attached, and it is the single most useful thing to have in a screenshot when someone reports that the site feels slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five second timer that has to be cleared
&lt;/h2&gt;

&lt;p&gt;The database check is a race, because a hanging connection should fail the check rather than hang the function until the platform kills it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dbTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The timer is cleared in `finally` - otherwise a fast query still leaves a&lt;/span&gt;
  &lt;span class="c1"&gt;// pending 5s timeout holding the serverless function open.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT 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;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;dbTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&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;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;Database timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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;dbTimeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dbTimeout&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;Promise.race&lt;/code&gt; settles as soon as the first promise settles, but it does not cancel the loser. When the query comes back in 67ms, that &lt;code&gt;setTimeout&lt;/code&gt; is still armed. On a serverless runtime the invocation does not finish while a timer is pending, so a 67ms health check bills like a five second one, every single time the monitor calls it. At one call a minute that is a quiet, permanent, entirely self-inflicted cost.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;clearTimeout&lt;/code&gt; in a &lt;code&gt;finally&lt;/code&gt; is four lines. It is also the difference between a health check that costs nothing and one that is the most expensive route in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  It must never be cached
&lt;/h2&gt;

&lt;p&gt;The last trap is framework shaped. Next.js will happily prerender a route handler that looks static, and a cached health check is worse than no health check: it reports the state of the world at build time, with a timestamp that makes it look live.&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-dynamic&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;revalidate&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and on the response:&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="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-cache, no-store, must-revalidate&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;Both are needed. The route segment config stops the framework and the CDN caching it; the response header stops everything between us and the monitor doing the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://cogniprep.app/api/health" rel="noopener noreferrer"&gt;cogniprep.app/api/health&lt;/a&gt;: the live response. Count the fields. There is no host, no version, no library, no region, no variable name.&lt;/li&gt;
&lt;li&gt;Open it in DevTools and look at the response headers: &lt;code&gt;cache-control: no-cache, no-store, must-revalidate&lt;/code&gt;, and no &lt;code&gt;x-vercel-cache: HIT&lt;/code&gt; no matter how many times you reload.&lt;/li&gt;
&lt;li&gt;Reload it a few times and watch &lt;code&gt;checks.database.responseTime&lt;/code&gt; move. That number is the check doing real work rather than reading a cached answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general rule I would now apply to any unauthenticated status route: every field has to justify itself to an audience of strangers. Statuses, durations and counts pass. Names, hosts, versions and error strings go to the log.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>security</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Our OG card is a Playwright screenshot, and the Chromium comes from the workspace next door</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 27 Sep 2026 15:41:54 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-og-card-is-a-playwright-screenshot-and-the-chromium-comes-from-the-workspace-next-door-4aek</link>
      <guid>https://dev.to/daniel_pertu/our-og-card-is-a-playwright-screenshot-and-the-chromium-comes-from-the-workspace-next-door-4aek</guid>
      <description>&lt;p&gt;The social card for &lt;a href="https://notifio.app" rel="noopener noreferrer"&gt;Notifio&lt;/a&gt; is a PNG at &lt;a href="https://notifio.app/og-image.png" rel="noopener noreferrer"&gt;/og-image.png&lt;/a&gt;, and it is produced by taking a Playwright screenshot of a string of HTML at build time.&lt;/p&gt;

&lt;p&gt;That is the second answer I have given to this problem. Another app in the same family renders its card with &lt;code&gt;next/og&lt;/code&gt;, from the same logo SVG, font files and palette the site itself uses, which I wrote up in &lt;a href="https://dev.to/daniel_pertu/our-link-preview-cards-are-drawn-by-code-from-the-same-three-files-as-the-site-43i8"&gt;our link preview cards are drawn by code, from the same three files as the site&lt;/a&gt;. I still think that is the right call there. Here I deliberately did the opposite, and the reasons are all about &lt;em&gt;where the render happens&lt;/em&gt; rather than about which renderer draws nicer text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * It is a build-time script, not a route. `next/og` would put a satori render on
 * the request path and cannot read the woff2 files the app already ships, and an
 * OG card that changes once a quarter has no business being computed per
 * request. Playwright comes from the desktop app workspace, which already
 * depends on it and already has a Chromium downloaded, so the server package
 * takes on no new dependency for a script that runs by hand.
 *
 * Run from server/:  node scripts/generate-og-image.mjs
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Borrowing a browser from the workspace next door
&lt;/h2&gt;

&lt;p&gt;Notifio is a repository with two halves: a marketing site in &lt;code&gt;server/&lt;/code&gt;, and the Electron desktop app in &lt;code&gt;app/&lt;/code&gt;. The app drives a real browser, so it already depends on Playwright and, because it ships Chromium inside the packaged app, it already has one downloaded on disk. I wrote about that bundling in &lt;a href="https://dev.to/daniel_pertu/shipping-playwrights-chromium-inside-a-packaged-electron-app-p85"&gt;shipping Playwright's Chromium inside a packaged Electron app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the script reaches sideways rather than adding a dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HERE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fileURLToPath&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;APP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HERE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../../app&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 before playwright is imported: it reads the browsers path at module load.&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;PLAYWRIGHT_BROWSERS_PATH&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;APP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright-browsers&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;chromium&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="k"&gt;import&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="nx"&gt;APP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node_modules/playwright/index.mjs&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;Three details in four lines, and two of them are things I got wrong first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dynamic import is load-bearing.&lt;/strong&gt; &lt;code&gt;PLAYWRIGHT_BROWSERS_PATH&lt;/code&gt; is read when the Playwright module initialises, not when you call &lt;code&gt;launch()&lt;/code&gt;. A static &lt;code&gt;import { chromium } from "playwright"&lt;/code&gt; is hoisted above the assignment, so the environment variable would be set after the value it is meant to influence has already been read, and the launch would go looking in the default cache. &lt;code&gt;await import()&lt;/code&gt; puts the import back in statement order. That only works at the top level because this is a &lt;code&gt;.mjs&lt;/code&gt; with top-level await available, which is one of the better arguments for writing repo scripts as ESM.&lt;/p&gt;

&lt;p&gt;If you have ever been confused about why setting an env var in your script had no effect on a library, this is usually the shape of it: the library read it during module initialisation and your assignment ran after the import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;??=&lt;/code&gt; rather than &lt;code&gt;=&lt;/code&gt;.&lt;/strong&gt; An explicitly provided &lt;code&gt;PLAYWRIGHT_BROWSERS_PATH&lt;/code&gt; still wins, which matters on a machine where the app workspace has not been installed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The channel has to be named.&lt;/strong&gt; This one cost me a genuinely confusing ten minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// `channel: "chromium"` picks the full browser the app workspace ships. The&lt;/span&gt;
&lt;span class="c1"&gt;// default would reach for chrome-headless-shell, which that workspace has no&lt;/span&gt;
&lt;span class="c1"&gt;// reason to download and therefore does not have.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chromium&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;Recent Playwright versions default &lt;code&gt;chromium.launch()&lt;/code&gt; to &lt;code&gt;chrome-headless-shell&lt;/code&gt;, a separate and smaller download. The desktop app needs a full browser and only installs that, so the default asks for an executable that is not in the directory even though the directory clearly contains a Chromium. The error reads like a corrupt install rather than a wrong channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fonts are the actual reason this is not next/og
&lt;/h2&gt;

&lt;p&gt;The card uses three typefaces, and they arrive by two different routes. Geist Mono comes out of &lt;code&gt;node_modules&lt;/code&gt; and gets inlined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Inlined so Chromium never has to resolve a file:// URL for them. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monoFont&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SERVER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node_modules/geist/dist/fonts/geist-mono&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&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;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&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="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&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="s2"&gt;`@font-face{font-family:'Geist Mono';font-weight:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;font-style:normal;src:url(data:font/woff2;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) format('woff2');}`&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;page.setContent()&lt;/code&gt; gives the page an &lt;code&gt;about:blank&lt;/code&gt;-ish origin, from which relative and &lt;code&gt;file://&lt;/code&gt; font URLs are a fight you do not need to have. Base64 in a data URL is bigger and completely reliable, and the file is thrown away thirty milliseconds later anyway.&lt;/p&gt;

&lt;p&gt;The other two come from Google Fonts with an ordinary &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt;, because the site loads them the same way through &lt;code&gt;next/font/google&lt;/code&gt;. That is why the screenshot waits on the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&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;OUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&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;That middle line is the one I would put on a poster. &lt;code&gt;networkidle&lt;/code&gt; tells you the requests finished. It does not tell you the font has been applied and the text re-laid-out, so a screenshot taken immediately after can catch the fallback face, with the wrong metrics and the wrong weight, in a 1200x630 image that you then ship to every social platform on the internet. It fails in the ugliest possible way: it looks fine locally when the font is warm in the HTTP cache, and wrong on a cold machine.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;document.fonts.ready&lt;/code&gt; is a promise that resolves when font loading and layout have settled. It is two words and it is the difference between a card that is correct and a card that is correct on your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2x asset with a 1x declaration
&lt;/h2&gt;

&lt;p&gt;The render is 1200x630 logical pixels at double density:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WIDTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HEIGHT&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;deviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file on disk is therefore 2400x1260 and about 215KB. The metadata still declares the logical size:&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="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/og-image.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Notifio&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;That is not an inconsistency, it is the point. &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt; describe the aspect and layout the consumer should reserve; the bitmap is allowed to be denser than that. A link preview in Slack or iMessage on a retina screen renders the card at a good physical size, and a 1x PNG of text at 74px looks soft in exactly the place where the card is doing its job.&lt;/p&gt;

&lt;p&gt;You can check the numbers yourself without downloading anything much:&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;# The PNG header carries the real dimensions in bytes 16..24.&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import struct;d=open('og-image.png','rb').read();print(struct.unpack('&amp;gt;II',d[16:24]))"&lt;/span&gt;
&lt;span class="c"&gt;# (2400, 1260)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The &lt;code&gt;next/og&lt;/code&gt; version of this in my other app cannot drift from the site, because it literally imports the site's three inputs. This one is weaker, and the weakness is in the CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* The same 40px grid the homepage CTA section lays over its background. */&lt;/span&gt;
&lt;span class="nc"&gt;.grid&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;90deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;background-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;40px&lt;/span&gt; &lt;span class="m"&gt;40px&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 same 40px grid" is a claim in a comment, not a shared constant. The fonts genuinely cannot drift, because the script reads the same woff2 files the site serves, and the logo cannot drift because it reads &lt;code&gt;public/icon-light.svg&lt;/code&gt;. But the palette and the grid are a copy, and if I restyle the homepage this file will not notice.&lt;/p&gt;

&lt;p&gt;I decided that was acceptable for a card that changes about once a quarter, and I would decide differently for anything rendered per-request or per-page. It is a real trade and I would rather write it down than pretend the script is more principled than it is.&lt;/p&gt;

&lt;p&gt;The pills at the bottom of the card have the same problem in a more dangerous form, since they quote a price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pill"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;pound;&lt;/span&gt;20 one-time&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pill"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;macOS &lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt; Windows&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pill"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;No subscription&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everywhere else on the site, that number is derived from the constant Stripe charges, so the &lt;a href="https://notifio.app/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; and the structured data cannot disagree with the invoice. In this file it is typed out. A build-time script that reads &lt;code&gt;LICENSE_PRICE_PENCE&lt;/code&gt; would have been three lines, and the only reason it does not is that the script predates my caring about it. That is a genuine bug waiting for a price change, and writing this paragraph is how I noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I took away
&lt;/h2&gt;

&lt;p&gt;The question was never "satori or Chromium". It was "what is this code allowed to depend on". A request-time renderer can only use what is in the serverless bundle, which rules out reading font binaries off disk and rules out launching a browser. A build-time script can use anything on the machine, including a browser that a sibling workspace downloaded for a completely different reason.&lt;/p&gt;

&lt;p&gt;Deciding where the render happens first, and then picking the tool, got me to a simpler answer than starting from the tool. And it is why the same problem has two different answers in two of my apps without either of them being wrong.&lt;/p&gt;

</description>
      <category>node</category>
      <category>playwright</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Our deploy CLI default origin is the one origin that can never work</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 27 Sep 2026 15:38:11 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-deploy-cli-default-origin-is-the-one-origin-that-can-never-work-2cdf</link>
      <guid>https://dev.to/daniel_pertu/our-deploy-cli-default-origin-is-the-one-origin-that-can-never-work-2cdf</guid>
      <description>&lt;p&gt;The marketing site for &lt;a href="https://notifio.app" rel="noopener noreferrer"&gt;Notifio&lt;/a&gt; has a few hundred pages, most of them generated from data: one per rental site under &lt;a href="https://notifio.app/alerts" rel="noopener noreferrer"&gt;/alerts&lt;/a&gt;, one per competitor under &lt;a href="https://notifio.app/compare" rel="noopener noreferrer"&gt;/compare&lt;/a&gt;, plus the guides at &lt;a href="https://notifio.app/guides" rel="noopener noreferrer"&gt;/guides&lt;/a&gt;. After a deploy that changes any of them, a script tells IndexNow, so Bing, Yandex, Seznam and Naver re-crawl the changed pages instead of waiting for their own schedule.&lt;/p&gt;

&lt;p&gt;I have written about the protocol's traps before, in &lt;a href="https://dev.to/daniel_pertu/a-200-from-indexnow-does-not-mean-it-read-your-key-33ff"&gt;a 200 from IndexNow does not mean it read your key&lt;/a&gt;, and about only pinging pages that actually changed in &lt;a href="https://dev.to/daniel_pertu/indexnow-but-only-when-the-page-actually-changed-38cn"&gt;IndexNow, but only when the page actually changed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is about something else entirely: the script has five separate ways to refuse to run, and writing them turned out to be most of the work. Not the HTTP call. The refusals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default is the one value that can never work
&lt;/h2&gt;

&lt;p&gt;Here is the default origin, resolved the obvious way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;site&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_APP_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_SITE&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;NEXT_PUBLIC_APP_URL&lt;/code&gt; is &lt;code&gt;http://localhost:3001&lt;/code&gt;. It says so in &lt;code&gt;.env&lt;/code&gt;, because that is what the Next dev server binds to and every other consumer of that variable wants exactly that.&lt;/p&gt;

&lt;p&gt;So the default origin for this command, in the environment where I will actually be typing the command, is a host the search engines cannot reach. IndexNow works by having the engine fetch a key file from your domain over the public internet. Point it at localhost and there is no version of the request that can succeed.&lt;/p&gt;

&lt;p&gt;Without a guard, that failure arrives as a &lt;code&gt;fetch failed&lt;/code&gt; on the key file check, which reads like a network problem and sends you off to look at your connection. The guard says the actual thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The engines have to fetch the key file over the public internet, so a local
 * origin can never work. NEXT_PUBLIC_APP_URL is localhost in development, which
 * makes this the likeliest way to run the command wrongly.
 */&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="nf"&gt;isPublicHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not a publicly reachable origin, so the engines could never fetch the key file.\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`  Pass the live origin explicitly, for example: pnpm indexnow --site &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEFAULT_SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ...`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing I would not have predicted is how much of the module that one check justifies. &lt;code&gt;isPublicHost&lt;/code&gt; is not a URL validator, it is specifically a "could a stranger's server reach this" test, and that means enumerating the private ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPublicHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&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;/:&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.localhost&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="kc"&gt;false&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;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;::1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="kc"&gt;false&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;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.internal&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Private and loopback IPv4 ranges, per RFC 1918 and RFC 5735.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ipv4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})&lt;/span&gt;&lt;span class="sr"&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;ipv4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ipv4&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;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&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;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&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;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;192&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;168&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;false&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;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;172&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;31&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;false&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;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;169&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;254&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;false&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;true&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 bare name with no dot cannot be a registrable domain.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty lines to catch a mistake that costs thirty seconds of confusion. Worth it, and it earned its keep a second time in a place I did not write it for. The &lt;code&gt;--init&lt;/code&gt; subcommand generates a key and then tells you where to go and confirm it, and that instruction is useless if it points at localhost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On a dev .env the origin is localhost, which is useless in the "now go&lt;/span&gt;
&lt;span class="c1"&gt;// confirm this URL" line, so fall back to the production site for display.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isPublicHost&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="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_SITE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general point: if your command's default comes from the environment, check whether the environment you will be typing it in supplies a value that works. Config inherited from a dev server is usually right for the dev server and wrong for anything that talks to the outside world. This is not defensive programming, it is the difference between a tool being usable and being a thing you have to remember a flag for.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the remote is all-or-nothing, partial success is a lie
&lt;/h2&gt;

&lt;p&gt;The protocol requires every URL in a submission to belong to the host you name in the payload, and a single foreign URL causes the engine to reject the whole batch. Not to skip that URL. To reject the request.&lt;/p&gt;

&lt;p&gt;That fact decides the behaviour of the client, and the library's own header says why the filtering has to happen before the request rather than after:&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="cm"&gt;/**
 *  - Every URL in one request must live on the same host as `host`, and the
 *    whole request is rejected if any single URL does not. Filtering therefore
 *    has to happen before submitting, not after a 422 comes back.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting decision is what the CLI does with a stray. It could drop it and submit the rest. It does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offHost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;partitionByHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;host&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;offHost&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A single foreign URL makes the engines reject the entire batch, so this is&lt;/span&gt;
  &lt;span class="c1"&gt;// fatal rather than a warning: submitting the remainder silently would hide a&lt;/span&gt;
  &lt;span class="c1"&gt;// mistyped hostname behind a successful-looking run.&lt;/span&gt;
  &lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`These URLs are not on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:\n  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offHost&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="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mistyped hostname in the argument list is almost always a page I meant to submit. Filtering it out and printing a tick would mean the run "succeeded" while the page I cared about was never announced, and I would not find out for weeks. The library still returns the strays rather than discarding them, for the same reason: they are evidence, not noise.&lt;/p&gt;

&lt;p&gt;Compare that with what is right next to it, which is deliberately not fatal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onHost&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;unique&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;lt;&lt;/span&gt; &lt;span class="nx"&gt;onHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;onHost&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;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`• Removed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dropped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; duplicate &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;URL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;URLs&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Duplicates are untidy. Off-host URLs are a rejection. The test I ended up applying to every input problem: would the remote refuse this, or is it merely inelegant? Refusal is fatal, inelegance is a note.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the thing that fails invisibly
&lt;/h2&gt;

&lt;p&gt;A wrong key does not produce a helpful error. It produces a 403 with no body, and nothing on your site looks broken, so the failure mode is "pages quietly stopped being re-crawled by everything except Google". That is why the submission is preceded by a fetch of your own key file:&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="cm"&gt;/**
 * Confirms the hosted key file exists and matches the key being submitted.
 *
 * Worth doing before every run: a key mismatch is the single most common way
 * this integration breaks, and it fails as a 403 on the submission itself,
 * which is far harder to read than "the file at this URL says something else".
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the check reports the mismatch in the most boring, most useful way available, by quoting what it found:&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;body&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="nx"&gt;response&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="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;trim&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;body&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&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;60&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;...`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(empty)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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="nx"&gt;url&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="s2"&gt;`contains &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, expected &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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The refusal that follows tells you the consequence and the escape hatch in two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`Key file &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;check&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="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;  Every submission will 403 until it matches. Fix it, or pass --skip-verify to submit anyway.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming the escape hatch in the error message matters more than it sounds. A guard with no documented way past it is a guard somebody will eventually delete, because the one time it is wrong they will be in a hurry. &lt;code&gt;--skip-verify&lt;/code&gt; exists so that the answer to "the check is wrong and I need to ship" is a flag rather than a commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refuse the ambiguous invocation instead of guessing
&lt;/h2&gt;

&lt;p&gt;Two flavours of this, and neither is clever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sitemap&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pass either --sitemap or an explicit list of URLs, not both.&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;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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sitemap&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;urls&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Nothing to submit. Pass one or more URLs, or --sitemap.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--sitemap&lt;/code&gt; with extra URLs could plausibly mean "the sitemap plus these", so it could have been a union. I made it an error because I cannot tell which of the two the person typing it meant, and a tool that guesses at intent in a command that talks to four search engines is a tool that will one day submit 235 URLs when you meant three.&lt;/p&gt;

&lt;p&gt;The empty case is the same principle from the other side. Zero URLs is not a successful run of nothing, it is an invocation that did not say what it wanted.&lt;/p&gt;

&lt;p&gt;Unknown engines get the same treatment, with the valid set in the message so you do not have to go and read the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasOwn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_ENDPOINTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;die&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Unknown engine: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Expected one of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_ENDPOINTS&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="s2"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Object.hasOwn&lt;/code&gt; rather than &lt;code&gt;in&lt;/code&gt; or a truthiness check, so &lt;code&gt;--engine constructor&lt;/code&gt; is an unknown engine rather than a crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit immediately, or exit at the end
&lt;/h2&gt;

&lt;p&gt;There are two ways this script can fail, and they use different mechanisms on purpose.&lt;/p&gt;

&lt;p&gt;Everything above is &lt;code&gt;die()&lt;/code&gt;, which prints and calls &lt;code&gt;process.exit(1)&lt;/code&gt; straight away. Nothing has been sent at that point, so there is nothing to finish and no reason to continue.&lt;/p&gt;

&lt;p&gt;Once the submission loop starts, it switches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;batch&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;batches&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="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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meaning&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;failed&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`✗ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;batches&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="s2"&gt; batches failed`&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;exitCode&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed batch does not abort the run, because the other batches are independent and half a submission is better than none. And the exit status is set rather than taken, so the pending output flushes before the process leaves.&lt;/p&gt;

&lt;p&gt;That split is worth adopting as a habit. &lt;code&gt;process.exit&lt;/code&gt; while you still have work or unflushed output is how you lose the line you needed; &lt;code&gt;process.exitCode&lt;/code&gt; while you are validating arguments is how you accidentally continue into the thing you just decided not to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refusal that is only a comment
&lt;/h2&gt;

&lt;p&gt;One thing the script does not enforce, and I went back and forth on it. The docblock says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; * Submitting the same URL repeatedly with no change to the page is the one
 * documented way to get throttled, so prefer passing the handful of pages a
 * deploy actually touched over running --sitemap out of habit.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--sitemap&lt;/code&gt; exists and pulls every URL out of the live &lt;a href="https://notifio.app/sitemap.xml" rel="noopener noreferrer"&gt;sitemap.xml&lt;/a&gt;, which for this site is a few hundred pages. Running it after every deploy would be the easy habit and the wrong one.&lt;/p&gt;

&lt;p&gt;I could have made the script refuse &lt;code&gt;--sitemap&lt;/code&gt; unless some page actually changed, and there is a separate mechanism that answers that question. But putting that logic here would mean this command needed to know about build output and content hashes, which is a lot of coupling for a script whose job is one POST. So it stays a sentence in the usage text, and the enforcement lives where it belongs.&lt;/p&gt;

&lt;p&gt;Not every rule you want should be a guard. The ones worth building are the ones where the mistake is silent, and "I submitted too many URLs" is not silent, it is throttling with a 429 and a documented cause. The five refusals above all exist because their failures were invisible.&lt;/p&gt;

</description>
      <category>node</category>
      <category>cli</category>
      <category>seo</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
