<?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: MinSoo Kim</title>
    <description>The latest articles on DEV Community by MinSoo Kim (@danorie).</description>
    <link>https://dev.to/danorie</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%2F4116712%2F925d8dc1-fd2b-4774-b495-dcbef4eb87e5.png</url>
      <title>DEV Community: MinSoo Kim</title>
      <link>https://dev.to/danorie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danorie"/>
    <language>en</language>
    <item>
      <title>The comparison operator that returns true when there is nothing to compare</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Fri, 25 Sep 2026 12:02:49 +0000</pubDate>
      <link>https://dev.to/danorie/the-comparison-operator-that-returns-true-when-there-is-nothing-to-compare-4f85</link>
      <guid>https://dev.to/danorie/the-comparison-operator-that-returns-true-when-there-is-nothing-to-compare-4f85</guid>
      <description>&lt;p&gt;I've been trying to add a "block the checkout if this rule matches" feature to a Shopify app I run, and I nearly shipped something that would have blocked every single cart in a store. Not some carts. All of them. The bug was one line long and it was already in production, doing something perfectly reasonable, and you'd have to squint to see anything wrong with it. Let's go through it, because I think the shape of it is a lot more common than the specific code, and you probably have one of these somewhere too.&lt;/p&gt;

&lt;p&gt;Quick context so you can follow. The app has a small rules engine: 15 kinds of conditions (shipping country, cart total, product, customer tag, whether the address looks like a PO box, that sort of thing), written once in TypeScript and shared between the checkout function that runs as wasm and the rule tester inside the app. The two existing targets use those rules to hide a payment method or to rename a delivery option. The new work was supposed to be small: same condition vocabulary, one more output, "block". Easy, right? I thought so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the function runs
&lt;/h2&gt;

&lt;p&gt;So this is the part I didn't know. Shopify's Cart and Checkout Validation Function is not a checkout-only thing. It runs at 3 points in the buyer's journey: &lt;code&gt;CART_INTERACTION&lt;/code&gt;, &lt;code&gt;CHECKOUT_INTERACTION&lt;/code&gt; and &lt;code&gt;CHECKOUT_COMPLETION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The existing two targets only ever ran inside checkout, and the worst they could do to a buyer was hide a payment method a bit early or rename a delivery option. So for as long as that engine has been running, a wrong answer on a cart with no country had never cost anyone a sale. But at the cart stage there is no address yet. Nobody has typed one. And now the wrong answer would stop the sale. Obvious in hindsight? Completely. Did I think of it? I did not.&lt;/p&gt;

&lt;p&gt;That is the whole bug, really. The rest is just working out which line it will land on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&lt;/h2&gt;

&lt;p&gt;Our comparison operators don't fail closed. The &lt;code&gt;not_in&lt;/code&gt; branch explicitly returns &lt;code&gt;true&lt;/code&gt; when the value is missing. This isn't me guessing after the fact; it's what the code says:&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;// shared/rules-engine/evaluate.ts&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not_in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that with a merchant's rule in mind: "block if the shipping country is not in [US]". On a cart with no address, &lt;code&gt;a&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, so the operator says &lt;em&gt;yes, the country is not in the list&lt;/em&gt;, then the rule matches, then the cart gets blocked. And at the cart stage every cart looks like that. From the moment a merchant turns that rule on, nobody will be able to get past the cart. What's the logic in that? There isn't any; it's just what the code does when you ask it to answer a question it wasn't built to answer.&lt;/p&gt;

&lt;p&gt;I want to be precise about what actually happened, because "blocked every order" is the kind of sentence that gets repeated. This lived on a branch (&lt;code&gt;feat/validation-rules&lt;/code&gt;). It never reached a merchant. The live app can't block anything and still has its 15 conditions. But if I had shipped the branch as it stood, that is what it would have gone on to do.&lt;/p&gt;

&lt;p&gt;4 of the 15 conditions read the address: &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;province&lt;/code&gt;, &lt;code&gt;zip&lt;/code&gt; and &lt;code&gt;po_box&lt;/code&gt;. The branch adds 4 more address-shaped ones (length, whether there's a street number, contains-string, non-Latin characters), so on the branch it's 8 of 19 that depend on something a cart doesn't have yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious fixes don't work
&lt;/h2&gt;

&lt;p&gt;So my first thought was the obvious one: make &lt;code&gt;not_in&lt;/code&gt; return &lt;code&gt;false&lt;/code&gt; when the value is missing. Fail closed, done. Right?&lt;/p&gt;

&lt;p&gt;But that operator is not mine to change any more. It's shared with the two targets that are already live, and merchants have built their rules on top of what it does today. Flip the operator and I'd be changing the behaviour of software that merchants have configured and rely on, to fix a feature nobody is using yet. I'm not going to do that.&lt;/p&gt;

&lt;p&gt;Second thought: use &lt;code&gt;buyerJourney.step&lt;/code&gt; and just skip address rules at &lt;code&gt;CART_INTERACTION&lt;/code&gt;. That's what the API is giving you the step for, surely? Surely?&lt;/p&gt;

&lt;p&gt;It's not enough. The address can also be empty &lt;em&gt;during&lt;/em&gt; checkout, before the buyer has got round to filling in the address form. So a rule can be evaluated at &lt;code&gt;CHECKOUT_INTERACTION&lt;/code&gt; with the same missing value and the same wrong &lt;code&gt;true&lt;/code&gt;. Filtering by step fixes the cart case and then leaves the checkout case exactly as broken as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did instead
&lt;/h2&gt;

&lt;p&gt;So the engine now refuses to answer instead of guessing, which I'm pretty happy with. Any rule that needs an address is held until the cart actually has one, and the function decides that by looking at the cart, not at which step it is on.&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;ADDRESS_CONDITION_FIELDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConditionField&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;country&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;province&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;zip&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;po_box&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// …plus the 4 address-shape conditions (length, street number, contains, non-Latin)&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;ruleNeedsAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rule&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ADDRESS_CONDITION_FIELDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Function side. Not buyerJourney.step: does the cart have an address or not.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addressKnown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryGroups&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&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;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deliveryAddress&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;countryCode&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;errors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluateValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;addressKnown&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule tester inside the app doesn't take a default. It works out &lt;code&gt;addressKnown&lt;/code&gt; from what the merchant actually typed: if they filled in a country, the rule is evaluated; if they left it blank, it shows "waiting for address" rather than silently matching, which is the same thing the checkout does, just made visible to them.&lt;/p&gt;

&lt;p&gt;And there are regression tests pinning both directions now: cart stage with no address lets the cart through, checkout stage with an address blocks it. The original 4 (&lt;code&gt;country&lt;/code&gt;, &lt;code&gt;province&lt;/code&gt;, &lt;code&gt;zip&lt;/code&gt;, &lt;code&gt;po_box&lt;/code&gt;) never get evaluated on an address-less cart at all, because &lt;code&gt;ruleNeedsAddress&lt;/code&gt; holds the whole rule first. The 4 new address-shape conditions have their own test, and one sentence I made it say out loud, because it's the sentence I got wrong: on a cart with no address, all 4 of them answer "no", &lt;em&gt;including the negative forms&lt;/em&gt; (&lt;code&gt;not_in&lt;/code&gt;, &lt;code&gt;not_set&lt;/code&gt;). 173 tests pass on the branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that generalises
&lt;/h2&gt;

&lt;p&gt;The same operator is safe when the worst it can do is hide a payment button, and dangerous when it can stop a sale. Nothing about the operator changed between those two sentences. The thing that changed is how the caller reacts to a &lt;code&gt;true&lt;/code&gt;. Is the operator wrong, then? I don't think it is. Is it dangerous? Obviously, in the right place.&lt;/p&gt;

&lt;p&gt;So "fail open or fail closed?" isn't a property of the comparison. It's a property of the comparison &lt;em&gt;plus&lt;/em&gt; whoever is holding the result. An engine that returns &lt;code&gt;true&lt;/code&gt; for "I don't know" has a blast radius decided entirely by its callers, and when you reuse it somewhere new you're not just importing the vocabulary of conditions. You're importing the execution context that vocabulary was written in, and here that context was "the worst a wrong answer can do is hide a button". It wasn't written down anywhere. It was just true, until it wasn't.&lt;/p&gt;

&lt;p&gt;I don't have a clever rule for catching this in general, sorry. The honest version is: when a shared function gets a new caller, go and read every branch that handles missing input, one by one, and ask yourself what the new caller will do with each answer. Then write that down. It's boring, but it would have been a lot more expensive to learn after launch.&lt;/p&gt;

&lt;p&gt;Anyway, the branch is still a branch, and if you've got a shared rules engine of your own, I'd really go and read its missing-value branches this week. It gets the last checks (typegen, wasm build, a dev store run-through) before it goes anywhere near a real merchant, and now I'm slightly more nervous about the other 11 conditions than I was last week, which is probably the correct amount of nervous.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why CookieMop waits 15 seconds after you close a tab, and why that is the hard part in Manifest V3</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Sat, 19 Sep 2026 12:03:56 +0000</pubDate>
      <link>https://dev.to/danorie/why-cookiemop-waits-15-seconds-after-you-close-a-tab-and-why-that-is-the-hard-part-in-manifest-v3-phm</link>
      <guid>https://dev.to/danorie/why-cookiemop-waits-15-seconds-after-you-close-a-tab-and-why-that-is-the-hard-part-in-manifest-v3-phm</guid>
      <description>&lt;p&gt;CookieMop is a Chrome extension that deletes a site's cookies when you close its last tab, unless the site is on your whitelist. One sentence of product. The interesting engineering is in three words of that sentence: "last", "close", and the delay hiding between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not delete immediately
&lt;/h2&gt;

&lt;p&gt;Closing a tab by accident is common, and cookies are what keep you logged in. So the extension does not clean on the close event. It schedules a cleanup 15 seconds later (the default; users can change it, and zero means immediate) and only then checks whether the site is still gone. Reopen the tab within the window and the pending cleanup is cancelled. One Playwright test exists for exactly that path: revisiting the site during the delay cancels the cleanup.&lt;/p&gt;

&lt;p&gt;That sounds like a &lt;code&gt;setTimeout&lt;/code&gt;. In Manifest V3 it cannot be only a &lt;code&gt;setTimeout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The service worker will not wait for you
&lt;/h2&gt;

&lt;p&gt;In Manifest V3 the background script is a service worker, and Chrome can terminate it whenever it is idle. A timer living inside a worker that gets killed at second 9 of a 15-second wait never fires. The old Manifest V2 answer, a persistent background page, is gone.&lt;/p&gt;

&lt;p&gt;The tool Chrome offers instead is &lt;code&gt;chrome.alarms&lt;/code&gt;, which survives the worker being unloaded. It has one catch that shaped the whole design: alarms are clamped to a minimum of 30 seconds. A 15-second delay cannot be expressed as an alarm.&lt;/p&gt;

&lt;p&gt;So the extension does both. While the worker is alive, a plain timer fires the cleanup on time. In parallel, every pending cleanup is written to storage with its &lt;code&gt;fireAt&lt;/code&gt; timestamp, and one alarm is set for the earliest of them, pushed out to at least 30 seconds if needed:&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;ALARM_MIN_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Chrome clamps MV3 alarms to a 30 s minimum&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;earliest&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;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;pendings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fireAt&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ALARM_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;earliest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ALARM_MIN_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the worker lives, the timer wins and the alarm finds nothing to do. If the worker dies, the alarm wakes a fresh worker, which reads the pending list from storage and processes whatever is due. There is a test for that too: kill the service worker, and the alarm still fires the pending cleanup. The cost of the fallback is that a cleanup can land at 30 seconds instead of 15 when Chrome happened to unload the worker. I decided that late is fine and never is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Last tab" means last tab of the site, not the page
&lt;/h2&gt;

&lt;p&gt;The second word. If you have &lt;code&gt;mail.google.com&lt;/code&gt; in one tab and &lt;code&gt;docs.google.com&lt;/code&gt; in another, closing one must not log you out of the other. So the unit of tracking is not the hostname but the registrable domain, the eTLD+1: &lt;code&gt;mail.google.com&lt;/code&gt; becomes &lt;code&gt;google.com&lt;/code&gt;, and &lt;code&gt;a.b.example.co.uk&lt;/code&gt; becomes &lt;code&gt;example.co.uk&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Doing that properly means knowing which suffixes are "public", and the full Public Suffix List is thousands of entries. CookieMop ships no dependencies and no build step, so it carries a compact table of the common two-part suffixes instead: &lt;code&gt;co.uk&lt;/code&gt;, &lt;code&gt;co.kr&lt;/code&gt;, &lt;code&gt;com.au&lt;/code&gt;, &lt;code&gt;co.jp&lt;/code&gt;, 257 entries in total. A hostname ending in one of those needs three labels to be a site; anything else needs two. IP addresses and single-label hosts like &lt;code&gt;localhost&lt;/code&gt; are left as they are. It is not the whole list, and I would rather say that here than pretend. It covers the cases people actually log in to.&lt;/p&gt;

&lt;p&gt;The same table decides the lists. A whitelist rule on a subdomain survives a cleanup of its parent domain, and a more specific whitelist entry beats a greylist entry. Greylisted sites are the third state: they survive tab close and are cleaned when the browser restarts, in a startup pass that runs before you have opened anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race nobody sees
&lt;/h2&gt;

&lt;p&gt;There is a comment in the background script that I keep because it cost an afternoon. Recording a tab's domain on &lt;code&gt;tabs.onUpdated&lt;/code&gt; and taking it back on &lt;code&gt;tabs.onRemoved&lt;/code&gt; are both async read-then-write round trips to storage. If a tab is opened and closed fast enough, the "remove" read can overtake the still-pending "record" write, and the extension thinks a site was never open. The fix is a small promise queue that serializes every tab-record read and write, and the reason it is worth writing down is that it never shows up in manual testing. It shows up in a Playwright run that opens and closes tabs faster than a person can.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "clean" means
&lt;/h2&gt;

&lt;p&gt;By default only cookies are removed. The user can widen the scope to localStorage, or to all site data: IndexedDB, CacheStorage and service worker registrations, through &lt;code&gt;chrome.browsingData&lt;/code&gt;. Widening it is a real trade-off, because the same storage that tracks you also holds a half-written draft on some sites, so the narrower default stays.&lt;/p&gt;

&lt;p&gt;Everything runs locally. The extension makes no network requests, has no accounts and no analytics, and the repository on GitHub is the code that ships to the store. If you want to check the timer logic yourself, &lt;code&gt;src/background.js&lt;/code&gt; is about 430 lines and the alarm fallback is the part worth reading first.&lt;/p&gt;

&lt;p&gt;Have you ever lost a login to a tab you closed by mistake, or is 15 seconds too long for the way you browse?&lt;/p&gt;

&lt;p&gt;The extension is here: &lt;a href="https://chromewebstore.google.com/detail/nbehnialaodjcffgjkckbnocggbdmdel" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/nbehnialaodjcffgjkckbnocggbdmdel&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;chrome.alarms API, minimum alarm period in Manifest V3: &lt;a href="https://developer.chrome.com/docs/extensions/reference/api/alarms" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/extensions/reference/api/alarms&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Extension service worker lifecycle (idle termination): &lt;a href="https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Public Suffix List, what "registrable domain" means: &lt;a href="https://publicsuffix.org/" rel="noopener noreferrer"&gt;https://publicsuffix.org/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CookieMop source, the exact code shipped to the Chrome Web Store: &lt;a href="https://github.com/thoopring/cookiemop" rel="noopener noreferrer"&gt;https://github.com/thoopring/cookiemop&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>browser</category>
    </item>
    <item>
      <title>The 10 percent that bought nothing: what a flooring calculator should actually tell you</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:02:49 +0000</pubDate>
      <link>https://dev.to/danorie/the-10-percent-that-bought-nothing-what-a-flooring-calculator-should-actually-tell-you-1ene</link>
      <guid>https://dev.to/danorie/the-10-percent-that-bought-nothing-what-a-flooring-calculator-should-actually-tell-you-1ene</guid>
      <description>&lt;p&gt;Wall Math is a set of three small calculators: paint, wallpaper, flooring. The flooring one is the simplest arithmetic on the site and the one that taught me the most about what a calculator owes its user. The whole engine function is nine lines. Here is the part that matters:&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;requiredArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;requiredArea&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requiredArea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;purchasedArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;excessArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;roundingExtra&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;perBox&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;requiredArea&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;Area, allowance, divide by the carton, round up. Every flooring calculator on the internet does this. The difference is in what gets returned, and the two extra fields at the bottom are the reason this post exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of "extra"
&lt;/h2&gt;

&lt;p&gt;When you buy flooring you always take home more than your floor. There are two reasons, and they are not the same kind of thing.&lt;/p&gt;

&lt;p&gt;The first is the allowance you chose for cuts, mistakes and a spare plank or two. Ten percent is the number people repeat. It is a decision, and it is yours.&lt;/p&gt;

&lt;p&gt;The second is that cartons come in fixed sizes. You cannot buy 3.86 cartons, so you buy 4, and the last carton's remainder lands on your garage floor whether you wanted it or not. That is not a decision. It is arithmetic.&lt;/p&gt;

&lt;p&gt;Most calculators add these together and show one number: "you need 4 boxes." The user then reads that as "my 10 percent allowance is included," which is true, and concludes the allowance did something, which is often false.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worked example that changed the output
&lt;/h2&gt;

&lt;p&gt;A 12-by-15 foot living room is 180 square feet. Take three vinyl plank products from one manufacturer, all cut to the same 7-by-48-inch plank, with cartons of 51.33, 23.64 and 18.91 square feet. Those three figures come from Shaw's own product pages, read on 2026-09-10, and the exact styles are listed in the sources below.&lt;/p&gt;

&lt;p&gt;With a 10 percent allowance, the area to cover becomes 198 square feet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Carton&lt;/th&gt;
&lt;th&gt;198 ÷ carton&lt;/th&gt;
&lt;th&gt;Buy&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;51.33&lt;/td&gt;
&lt;td&gt;3.86&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;205.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23.64&lt;/td&gt;
&lt;td&gt;8.38&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;212.76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18.91&lt;/td&gt;
&lt;td&gt;10.47&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;208.01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now run the same floor with no allowance at all: 180 ÷ 51.33 is 3.51, so still 4 cartons. The other two drop to 8 and 10.&lt;/p&gt;

&lt;p&gt;For the largest carton, the 10 percent bought exactly nothing. Four cartons either way. That floor was already going to arrive with 25.32 square feet spare, because you cannot buy three and a half cartons, and 25.32 is more than the 18 square feet that a 10 percent allowance on 180 square feet adds. The rounding had already handed you more than the allowance was ever going to.&lt;/p&gt;

&lt;p&gt;On the smallest carton, the same 10 percent is the difference between 10 and 11 cartons. Real money, and also real safety, since dropping it leaves 9.10 square feet spare instead of 28.01.&lt;/p&gt;

&lt;p&gt;Same room. Same rule of thumb. It costs a carton in one case and zero in another, and the thing that decides which is a number printed on the side of the box that most people have never looked up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the calculator returns now
&lt;/h2&gt;

&lt;p&gt;So the result object separates them. &lt;code&gt;excessArea&lt;/code&gt; is everything above the measured floor. &lt;code&gt;roundingExtra&lt;/code&gt; is only the part above the area you asked for, the part you could not have avoided at that carton size. The page shows both, and a small chart on the rounding guide draws three bars for a 120 square foot floor: the floor, the floor with 10 percent, and what you actually buy in 23.64 square foot cartons. At 0, 10 and 15 percent allowance that floor is 6 cartons every time. Six, six, six. The allowance slider moves and the answer does not.&lt;/p&gt;

&lt;p&gt;I think that is the single most useful thing the page does. Not the count. The count any spreadsheet can produce. The useful part is telling someone, before they drive to the store, that the decision they are agonising over changes nothing for their carton, or that it changes exactly one carton and here is what that carton buys them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody upstream publishes the 10 percent
&lt;/h2&gt;

&lt;p&gt;We went looking for where the rule comes from. Shaw's vinyl planning and installation page and its flooring overview page, both read on 2026-09-10, state no waste or overage percentage and give no quantity guidance at all. What they do say is to "always work from multiple cartons to achieve a uniform appearance," which is worth knowing if your plan was to open one box at a time and return the rest.&lt;/p&gt;

&lt;p&gt;So the calculator labels the allowance as the user's input, not a recommendation. The assumption string in the result says so in plain words, and the page repeats it. Which number did you assume was a rule the last time you bought flooring?&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking arithmetic you publish
&lt;/h2&gt;

&lt;p&gt;Every worked number in the guides, including the 0 to 15 percent allowance table, was computed by hand and matched against the engine's regression tests before the pages went live. That is slow, and it is the only way I know to publish a table of numbers under my own name without flinching. The whole formula fits on one line: floor area × (1 + allowance) ÷ square feet per carton, rounded up. The work is in saying which part of the answer was your choice.&lt;/p&gt;

&lt;p&gt;The calculator is here: &lt;a href="https://www.wallmath.com/flooring-calculator/" rel="noopener noreferrer"&gt;https://www.wallmath.com/flooring-calculator/&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Shaw Prime Plank, style 0616V: 51.33 sq ft per carton, 7 in × 48 in planks: &lt;a href="https://shawfloors.com/en-us/vinyl/prime-plank-greyed-oak-7-/0616v-00532" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/vinyl/prime-plank-greyed-oak-7-/0616v-00532&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shaw Distinction Plus, style 2045V: 23.64 sq ft per carton: &lt;a href="https://shawfloors.com/en-us/vinyl/distinction-plus-barrel-oak-7-/2045v-07066" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/vinyl/distinction-plus-barrel-oak-7-/2045v-07066&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shaw Paladin Plus, style 0278V: 18.91 sq ft per carton: &lt;a href="https://shawfloors.com/en-us/vinyl/paladin-plus-fresh-pine-7-/0278v-05052" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/vinyl/paladin-plus-fresh-pine-7-/0278v-05052&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shaw vinyl planning and installation page, no waste percentage stated (read 2026-09-10): &lt;a href="https://shawfloors.com/en-us/plan-and-install/vinyl" rel="noopener noreferrer"&gt;https://shawfloors.com/en-us/plan-and-install/vinyl&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Wall Math rounding guide (allowance vs whole-box rounding): &lt;a href="https://www.wallmath.com/flooring-boxes/" rel="noopener noreferrer"&gt;https://www.wallmath.com/flooring-boxes/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>javascript</category>
      <category>math</category>
    </item>
    <item>
      <title>The checkout never calls our server: how HideKit's rules run inside Shopify</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:03:29 +0000</pubDate>
      <link>https://dev.to/danorie/the-checkout-never-calls-our-server-how-hidekits-rules-run-inside-shopify-2b5i</link>
      <guid>https://dev.to/danorie/the-checkout-never-calls-our-server-how-hidekits-rules-run-inside-shopify-2b5i</guid>
      <description>&lt;p&gt;HideKit is a small Shopify app. A merchant writes rules like "hide cash on delivery for orders under ₩100,000 shipped to Korea" or "rename Standard Shipping to Ground for US addresses", and the checkout follows them. If ten thousand merchants installed it tomorrow, our server would not get one extra request at checkout time. That is the part I want to explain, because it decided almost everything else about how the app is built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rules live
&lt;/h2&gt;

&lt;p&gt;The rules do not live in our database. They live in one JSON document in a Shopify metafield attached to the merchant's payment customization, under the namespace &lt;code&gt;$app:payment-rules&lt;/code&gt; with the key &lt;code&gt;function-configuration&lt;/code&gt;. A second customization holds the delivery rules. Our admin app writes to that metafield, and nothing else of ours is involved after that.&lt;/p&gt;

&lt;p&gt;At checkout, Shopify runs a Function. Ours targets &lt;code&gt;cart.payment-methods.transform.run&lt;/code&gt;, and a sibling targets &lt;code&gt;cart.delivery-options.transform.run&lt;/code&gt;. Shopify hands the function an input it asked for in a GraphQL query: the cart total and currency, discount applications, the buyer's company if it is a B2B checkout, the customer's amount spent, order count and tags, the delivery address down to province and zip, each line's weight and collection membership, and the checkout language. The function reads the metafield, evaluates the rules against that input, and returns a list of operations: hide this method, rename that one, move this one to a different position. Three operation types, one metafield read, zero network calls. That is the whole runtime. If our servers are down, the checkout does not notice.&lt;/p&gt;

&lt;p&gt;The function is compiled to WebAssembly and executed by Shopify, so the engine had to be plain TypeScript with no I/O. That constraint turned out to be a gift.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, two callers
&lt;/h2&gt;

&lt;p&gt;Because the engine is a pure module, &lt;code&gt;shared/rules-engine&lt;/code&gt;, the admin app can import the same code. HideKit has a page called the Rule Tester where a merchant types in a cart: country, total, products, tags, and a list of payment methods (the default is three: cash on delivery, credit card, bank deposit). The page runs the merchant's saved rules through the same &lt;code&gt;evaluateRules&lt;/code&gt; function the checkout uses and shows which rules fire and what each method would look like. No test orders, no fake customers, and no chance that the tester and the checkout disagree, because there is only one implementation.&lt;/p&gt;

&lt;p&gt;"Why isn't it hidden?" is the support question every app in this category gets. The tester exists so the merchant can answer it themselves before a customer ever sees the checkout. Which method do your customers keep picking that you wish they wouldn't? That is usually the first rule a merchant writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a thing you cannot step through
&lt;/h2&gt;

&lt;p&gt;You cannot attach a debugger to a function running inside Shopify's checkout. So the tests run the compiled wasm against fixtures. There were 89 tests in the suite at the last count I wrote down, including an 18-case condition matrix run through the wasm build and boundary fixtures such as a rule at 20 with carts at 20 and 19.99, because "greater than or equal" is exactly the kind of thing that gets flipped in a refactor.&lt;/p&gt;

&lt;p&gt;The end-to-end check was done by hand on a development store with a rule that reads "country is KR AND total is at least ₩100,000": a ₩58,000 cart showed cash on delivery, a ₩116,000 cart hid it. Boring, and the only proof that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident that became a smoke test
&lt;/h2&gt;

&lt;p&gt;Cleaning up a development preview once removed the payment customization that owned the metafield. The rules were still visible in the merchant's admin, pointing at an owner that no longer existed. The fix was to recreate the customization with the released function's ID, and doing it through the app's own UI turned the fix into a full pass of the create path: UI to server to the customization-create mutation to &lt;code&gt;metafieldsSet&lt;/code&gt;. It is the most useful bug we have had, because it forced the exact flow a new merchant goes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the architecture cannot do
&lt;/h2&gt;

&lt;p&gt;Living inside Shopify means living with Shopify's limits, and we list them in the app instead of discovering them with a merchant. On non-Plus stores, checkout does not allow hiding the Shopify Payments gateway itself; only individual methods like cash on delivery or manual payments can be hidden. There is also a known checkout bug where a customer who selected a method that then becomes hidden can get stuck, so onboarding flags it up front. Two limits, both written on the listing, because a refund request is a worse place to learn about them.&lt;/p&gt;

&lt;p&gt;I would build it this way again. The rules being the merchant's data, in their store, evaluated by the platform, is not only a nice property for uptime. It means uninstalling the app leaves nothing behind in the theme, and it means the thing the merchant tests is the thing that runs.&lt;/p&gt;

&lt;p&gt;If you want to see the tester, the listing is here: &lt;a href="https://apps.shopify.com/hidekit" rel="noopener noreferrer"&gt;https://apps.shopify.com/hidekit&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Payment customization function target: &lt;a href="https://shopify.dev/docs/api/functions/reference/payment-customization" rel="noopener noreferrer"&gt;https://shopify.dev/docs/api/functions/reference/payment-customization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Delivery customization function target: &lt;a href="https://shopify.dev/docs/api/functions/reference/delivery-customization" rel="noopener noreferrer"&gt;https://shopify.dev/docs/api/functions/reference/delivery-customization&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shopify Functions overview (WebAssembly execution, input queries): &lt;a href="https://shopify.dev/docs/apps/build/functions" rel="noopener noreferrer"&gt;https://shopify.dev/docs/apps/build/functions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HideKit listing, including the platform limits section: &lt;a href="https://apps.shopify.com/hidekit" rel="noopener noreferrer"&gt;https://apps.shopify.com/hidekit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why our product data pipeline refuses Amazon as a source</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Sat, 12 Sep 2026 12:03:46 +0000</pubDate>
      <link>https://dev.to/danorie/why-our-product-data-pipeline-refuses-amazon-as-a-source-51j0</link>
      <guid>https://dev.to/danorie/why-our-product-data-pipeline-refuses-amazon-as-a-source-51j0</guid>
      <description>&lt;p&gt;I run a small site called Inch &amp;amp; Drawer. It answers one question: will this kitchen organizer actually fit in your drawer or cabinet? You type your inside measurements, and the site compares them against the published dimensions of every product it lists. Ten are live right now. Five more sit in draft.&lt;/p&gt;

&lt;p&gt;That sentence hides the whole problem. "Published dimensions" is doing a lot of work. Where do the numbers come from, and how do I know they describe the exact product someone might buy?&lt;/p&gt;

&lt;p&gt;Here is the rule set we ended up with, and the two bugs that shaped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two sources, and Amazon is never one of them
&lt;/h2&gt;

&lt;p&gt;Every product record needs two independent pages that agree on the model. The dimensions that get published on the site can only come from two kinds of pages: the manufacturer's own product page, or a retailer listing that prints the numbers itself.&lt;/p&gt;

&lt;p&gt;Amazon pages are allowed in the record, but only as evidence for the model match. They never supply a published number. The site links to Amazon through the Associates program, and I did not want a page that earns a commission to also be the page that vouches for the measurement. Those are different jobs. Mixing them would have been the wrong call, and I'd rather list ten products with traceable numbers than fifty with numbers I copied from a marketplace.&lt;/p&gt;

&lt;p&gt;Concretely, each product file carries a list of matched pages with a &lt;code&gt;kind&lt;/code&gt; on each one: &lt;code&gt;maker_site&lt;/code&gt;, &lt;code&gt;retailer_listing&lt;/code&gt;, &lt;code&gt;brand_storefront&lt;/code&gt;, &lt;code&gt;amazon_listing&lt;/code&gt;. The importer on the site side only reads width, depth and height from the first two kinds. The other two can confirm "yes, this is the same model," and that is all they can do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback nobody looked at
&lt;/h2&gt;

&lt;p&gt;The generator that builds these records had a fallback. If no evidence page was found for a product, it fell back to a URL from a targets file, so the record would at least point somewhere.&lt;/p&gt;

&lt;p&gt;For one shelf riser, that fallback URL was an Amazon brand storefront. The rest of the pipeline treated it like any other source URL, and the record went out marked as importable. The site's catalog test caught it on the other side: it rejects any record whose source URL is on Amazon, drafts included. The importer had been stricter only for publishable records, so we tightened it to match the test.&lt;/p&gt;

&lt;p&gt;The fix on the generator side was two lines of thinking. First, the fallback can no longer produce an Amazon URL. Second, there is now an assertion right before the file is written: if a primary source URL resolves to an Amazon domain, the write fails loudly. On its first run that assertion fired, which is how we found the fallback in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Midnight in the wrong time zone
&lt;/h2&gt;

&lt;p&gt;Each record has a &lt;code&gt;checked_at&lt;/code&gt; date. The site's freshness check reads that date as UTC midnight and refuses anything in the future. Our records are checked in Korea, nine hours ahead of UTC. Every morning between midnight and nine, a record checked "today" looked like it came from tomorrow, and the canary release failed on it.&lt;/p&gt;

&lt;p&gt;We now write a full timestamp with the offset, &lt;code&gt;checked_at_ts&lt;/code&gt;, next to the date. The importer converts that to a UTC date. If only the plain date exists, it takes the earlier of that date and today in UTC. Boring. It was also the only thing standing between the first automated product and the live site for about a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sentence is not a number
&lt;/h2&gt;

&lt;p&gt;One product page said the organizer needs a drawer that is "at least 3" high." The first import copied that whole sentence into the field meant for the printed value, so the card read like a quote inside a label. We changed it: the field holds the value as printed, &lt;code&gt;3"&lt;/code&gt;, and the sentence lives in the evidence file that the card cites. Rules like "print the value, keep the sentence" are easy to write down and easy to forget. The importer now rejects any requirement value longer than 24 characters or without a digit, which is a crude way of saying "that's a sentence, not a number."&lt;/p&gt;

&lt;p&gt;The pipeline reads pages logged out, at human speed, with a cap of 40 pages a day and a few seconds between requests. It is deliberately slow. Three products went live this week under these rules, and none of them needed a correction. The site publishes a handful of new products a week, and every one of them can be traced back to a page that printed the number.&lt;/p&gt;

&lt;p&gt;If you want to see what the output looks like, the fit-check page is here: &lt;a href="https://www.inchanddrawer.com/fit-check/" rel="noopener noreferrer"&gt;https://www.inchanddrawer.com/fit-check/&lt;/a&gt;. Each card shows where its dimensions came from, and marks the Amazon link as a paid link.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>data</category>
      <category>ecommerce</category>
      <category>showdev</category>
    </item>
    <item>
      <title>16 requests, 2 of them third-party, 0 ad requests (what a static calculator site actually loads)</title>
      <dc:creator>MinSoo Kim</dc:creator>
      <pubDate>Thu, 10 Sep 2026 06:26:07 +0000</pubDate>
      <link>https://dev.to/danorie/16-requests-2-of-them-third-party-0-ad-requests-what-a-static-calculator-site-actually-loads-2gbh</link>
      <guid>https://dev.to/danorie/16-requests-2-of-them-third-party-0-ad-requests-what-a-static-calculator-site-actually-loads-2gbh</guid>
      <description>&lt;p&gt;I wanted a calculator site that behaves like a document, not like a web app.&lt;/p&gt;

&lt;p&gt;Type three numbers. Get the number of paint cans to buy. No account, no dashboard, no round trip to a server, no analytics watching you type. The page, the arithmetic, and the answer.&lt;/p&gt;

&lt;p&gt;That one constraint decided almost every technical choice below, and it is also why the numbers are small enough to print in a table. Here is a cold load of wallmath.com, measured in a headless Chromium on 2026-09-10 against the live build:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Home page&lt;/th&gt;
&lt;th&gt;Guide page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total network requests&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests to my own domain&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party hosts contacted&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests to that host&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ad requests&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ad iframes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External stylesheets&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML document&lt;/td&gt;
&lt;td&gt;93,838 bytes raw, 26,621 gzipped&lt;/td&gt;
&lt;td&gt;97,078 bytes raw&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one third-party host is &lt;code&gt;pagead2.googlesyndication.com&lt;/code&gt;. It serves two script files and then stops, which is the part of this post I find most worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fonts: three files, subset, preloaded
&lt;/h2&gt;

&lt;p&gt;Two families: Fraunces for headlines, Source Serif 4 for body text. Loading them from a font CDN would be one line of HTML. Instead they are three woff2 files on my own origin.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fraunces-700-800-latin.woff2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;36,620&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sourceserif4-400-600-latin.woff2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50,824&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sourceserif4-italic-400-latin.woff2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20,092&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three decisions kept that to 107,536 bytes total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variable fonts collapse the weight axis.&lt;/strong&gt; Fraunces ships variable, so one file covers 700 and 800. Same for 400 and 600 in the body face. Six static files became two, and the two together are 87,444 bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latin subset only.&lt;/strong&gt; The copy is English and contains no Cyrillic or Greek, so those ranges are cut. It is the largest single saving available and it costs nothing, because those glyphs would never render.&lt;/p&gt;

&lt;p&gt;Each file sits in a &lt;code&gt;&amp;lt;link rel="preload" as="font" type="font/woff2" crossorigin&amp;gt;&lt;/code&gt; in the head and &lt;code&gt;@font-face&lt;/code&gt; uses &lt;code&gt;font-display: swap&lt;/code&gt;, so the fetch starts in the same round trip as the document instead of waiting for CSS to parse.&lt;/p&gt;

&lt;p&gt;The honest cost of self-hosting: I lose the shared-cache argument for CDN fonts. That argument is mostly gone anyway, because browsers partition the HTTP cache by top-level site, so a font your visitor already downloaded elsewhere does not help you.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS and JS live inside the document
&lt;/h2&gt;

&lt;p&gt;There are no external &lt;code&gt;.css&lt;/code&gt; or &lt;code&gt;.js&lt;/code&gt; files at all. The build inlines 27,933 bytes of CSS and 45,164 bytes of JS into every page, spread over six &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; blocks, the largest of which is 24,877 bytes.&lt;/p&gt;

&lt;p&gt;What it buys: first paint needs exactly one round trip. No stylesheet to discover, fetch and parse before anything renders. On guide pages, which are where search traffic lands, that is the difference between text appearing immediately and text appearing after a second request completes.&lt;/p&gt;

&lt;p&gt;What it costs: no cross-page caching. Someone who opens three guides downloads the same CSS three times. Gzip softens it, since the whole document compresses from 93,838 bytes to 26,621, but it is a real cost and it grows with the site.&lt;/p&gt;

&lt;p&gt;The number that decided it: the inlined document still compresses to under 30 KB. If the CSS doubled I would move it to a file and accept the round trip. I am not inlining on principle, I am inlining while the arithmetic says to.&lt;/p&gt;

&lt;p&gt;The JS is larger than the CSS because it holds the calculation engine, which runs entirely in the browser. Nothing about a reader's room dimensions leaves the page. There is no endpoint to send it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagrams are SVG, illustrations are AVIF
&lt;/h2&gt;

&lt;p&gt;The home page carries 15 inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; elements: wall elevations, plan views, the can and box pictograms, and the room drawing whose walls change colour when you click a swatch. They come out of the same build that renders the HTML, from the same numbers the calculator uses, so a dimension change updates the drawing and the result together.&lt;/p&gt;

&lt;p&gt;Inline SVG costs no extra requests and inherits the page's CSS custom properties for colour. The interactive part is DOM manipulation on those elements. No canvas, no chart library, no animation framework.&lt;/p&gt;

&lt;p&gt;The illustrations are &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; elements with an AVIF source and a WebP fallback, three widths each. What the browser actually fetched on a 1440px desktop view was the 480px AVIF files, because that is the size the cards render at:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;calc-paint-480.avif&lt;/code&gt; (what actually loaded)&lt;/td&gt;
&lt;td&gt;11,221&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;calc-paint-480.webp&lt;/code&gt; (fallback tier)&lt;/td&gt;
&lt;td&gt;18,514&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calc-paint-1200.avif&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42,493&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calc-paint-1200.webp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;78,512&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I list the fallback numbers because it is easy to quote your largest file and call it your image weight. The file that ships to most readers here is the 11 KB one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ad script that does not ask for an ad
&lt;/h2&gt;

&lt;p&gt;The site is being prepared for AdSense. I added the AdSense loader to the head as part of that preparation and deliberately paused ad requests until the ad placements and the consent flow are ready:&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;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"google-adsense-account"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"ca-pub-XXXXXXXXXXXXXXXX"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"&lt;/span&gt; &lt;span class="na"&gt;crossorigin=&lt;/span&gt;&lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;adsbygoogle&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="nx"&gt;adsbygoogle&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;pauseAdRequests&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pauseAdRequests = 1&lt;/code&gt; is documented by Google as a way to stop ad requests from being made, and setting it back to &lt;code&gt;0&lt;/code&gt; resumes them. The reason I want it is that the Auto ads setting lives in the account, not in my repository. It can be toggled from a browser I am not looking at, and I would rather the page refuse than trust that nobody does. Ads on this site are going in two places I chose, before the first subheading of a guide and at the end of it, and nowhere else.&lt;/p&gt;

&lt;p&gt;What the browser actually does with that markup, from my own network log on the live site: it fetches &lt;code&gt;adsbygoogle.js&lt;/code&gt;, that file fetches &lt;code&gt;show_ads_impl_fy2021.js&lt;/code&gt;, and nothing further. No &lt;code&gt;/pagead/ads&lt;/code&gt; call, no doubleclick host, no &lt;code&gt;gen_204&lt;/code&gt; beacon, no ad iframe. Two requests to one host and zero ad requests, on both the home page and a guide page.&lt;/p&gt;

&lt;p&gt;One detail if you try this: the loader always inserts a hidden &lt;code&gt;&amp;lt;ins class="adsbygoogle adsbygoogle-noablate"&amp;gt;&lt;/code&gt; into the body, sized 0 by 0 and display-none. It is created by the script, not by my markup, and it issues no request. My build-time check counts ad units in the HTML I generate, so a runtime element does not trip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it from regressing
&lt;/h2&gt;

&lt;p&gt;None of this holds unless something checks it on every build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A build verifier&lt;/strong&gt; renders all 17 pages and asserts 98 invariants: no &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; points at a host outside the site, every image has alt text, every internal link resolves, and the ad unit count on calculators, the home page and the trust pages is zero. The last run walked 470 links across 17 pages with no errors, and the release it validated is recorded as digest 63ee4554 in build-info.json.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A browser smoke suite&lt;/strong&gt; loads the built site in headless Chromium and records every request, 321 assertions in total, including which third-party hosts are contacted, that no ad request is made, that the calculators produce the expected numbers from typed input, and that no page overflows horizontally at 390px.&lt;/p&gt;

&lt;p&gt;A static gate on the ad markup keeps ad units on guide routes only. If an &lt;code&gt;&amp;lt;ins class="adsbygoogle"&amp;gt;&lt;/code&gt; appears anywhere else in the generated HTML, or the publisher ID in the markup does not match config, the build fails. Reserved ad space measured between 0 and 0.0103 cumulative layout shift, against 0.006 for the home page that has no ad slots at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Releases are signed before they ship
&lt;/h2&gt;

&lt;p&gt;Every release computes a digest over the exact input file set. Approving one means running a signing script with a private key kept outside the repository, which writes a signature for that single digest. A release gate then refuses to publish if what is about to ship has a different digest, or if the signature does not verify.&lt;/p&gt;

&lt;p&gt;So the claims in this post are attached to a reviewed input set rather than to a branch name. When the ad markup changed, the gate additionally refused to resume ad requests until a certified consent management platform was recorded in config, because that one is a compliance requirement and not a preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have not measured
&lt;/h2&gt;

&lt;p&gt;No field data. Every number here comes from the live production build over a normal connection and from a headless browser. There is no analytics on the site, which is the point, and also means I cannot tell you what a slow phone on a bad network sees.&lt;/p&gt;

&lt;p&gt;No comparison against a file-based build of the same content. The reasoning about round trips and gzip sizes is arithmetic, not an experiment.&lt;/p&gt;

&lt;p&gt;And the ad picture describes a site whose ads are off at three levels. When the units go live behind a consent flow, the third-party rows in that first table change, and the honest version of this post will need a new table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;None of this is a rule. Inlining CSS is wrong for a site with fifty templates and a returning audience. Self-hosting fonts is wrong if your team ships design changes weekly and needs the CDN tooling. Pausing ad requests is pointless once ads are live and consented.&lt;/p&gt;

&lt;p&gt;What travels is the habit underneath: measure what each decision costs on your own site, and keep the architecture no larger than the product needs. For three calculators that take three numbers and return a shopping list, that came out as local computation, a low request count, and a build I can reproduce and sign.&lt;/p&gt;

&lt;p&gt;If you have made the opposite call on any of these, what number decided it for you? I would rather hear the threshold than the principle.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Live pages measured, 2026-09-10: &lt;a href="https://www.wallmath.com/" rel="noopener noreferrer"&gt;https://www.wallmath.com/&lt;/a&gt; and &lt;a href="https://www.wallmath.com/guides/bedroom-paint-math-by-hand/" rel="noopener noreferrer"&gt;https://www.wallmath.com/guides/bedroom-paint-math-by-hand/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Font files measured directly: &lt;a href="https://www.wallmath.com/fonts/fraunces-700-800-latin.woff2" rel="noopener noreferrer"&gt;https://www.wallmath.com/fonts/fraunces-700-800-latin.woff2&lt;/a&gt; , &lt;a href="https://www.wallmath.com/fonts/sourceserif4-400-600-latin.woff2" rel="noopener noreferrer"&gt;https://www.wallmath.com/fonts/sourceserif4-400-600-latin.woff2&lt;/a&gt; , &lt;a href="https://www.wallmath.com/fonts/sourceserif4-italic-400-latin.woff2" rel="noopener noreferrer"&gt;https://www.wallmath.com/fonts/sourceserif4-italic-400-latin.woff2&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Image variants measured directly: &lt;a href="https://www.wallmath.com/illustrations/calc-paint-480.avif" rel="noopener noreferrer"&gt;https://www.wallmath.com/illustrations/calc-paint-480.avif&lt;/a&gt; and &lt;a href="https://www.wallmath.com/illustrations/calc-paint-1200.webp" rel="noopener noreferrer"&gt;https://www.wallmath.com/illustrations/calc-paint-1200.webp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Build metadata, including release digest and page count: &lt;a href="https://www.wallmath.com/build-info.json" rel="noopener noreferrer"&gt;https://www.wallmath.com/build-info.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google on pausing ad requests: &lt;a href="https://support.google.com/adsense/answer/9183363" rel="noopener noreferrer"&gt;https://support.google.com/adsense/answer/9183363&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HTTP cache partitioning, the reason a shared font CDN cache no longer helps: &lt;a href="https://developer.chrome.com/blog/http-cache-partitioning" rel="noopener noreferrer"&gt;https://developer.chrome.com/blog/http-cache-partitioning&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>html</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
