[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-post-curl-could-reach-it-node-could-not":3,"blog-post-rendered-curl-could-reach-it-node-could-not":15,"blog-all-for-curl-could-reach-it-node-could-not":30},{"slug":4,"title":5,"excerpt":6,"body":7,"cover":8,"tags":9,"publishedAt":12,"readingTime":13,"seoTitle":5,"seoDescription":14},"curl-could-reach-it-node-could-not","curl could reach it, Node could not","A deploy check reported a total outage against a site that was serving fine. The difference was 250 milliseconds of Happy Eyeballs, and it cost me an hour twice.","## The report that was wrong\n\nA post-deploy smoke script came back with twenty failures, all identical:\n\n```\nGET \u002F          TypeError: fetch failed\nGET \u002Fservices  TypeError: fetch failed\nGET \u002Fblog      TypeError: fetch failed\n```\n\nTwenty out of twenty-one checks. The site was down.\n\nExcept it was not. `curl` returned 200 for every one of those URLs, from the same machine, seconds apart.\n\n## Two address families, one of them a black hole\n\nThe host resolved to both an IPv4 address and an IPv6 address. The IPv4 worked. The IPv6 accepted nothing.\n\n`curl` implements Happy Eyeballs: try both families, keep whichever answers first. It reached the site on the first attempt every time.\n\nNode implements Happy Eyeballs too. `autoSelectFamily` has been on by default since Node 20, and I checked that it was on. So why did it hang?\n\nThe answer is the timeout sitting next to it. `autoSelectFamilyAttemptTimeout` defaults to 250 milliseconds. That is how long Node gives the first address before it moves to the next one. If the IPv4 connection has not completed inside that window, Node starts the IPv6 attempt as well, the IPv6 attempt goes nowhere, and the request sits there until the whole thing times out.\n\n250ms is not much for a TLS handshake to a shared host on another continent.\n\n## The fix is two lines\n\n```js\nimport net from 'node:net'\n\nnet.setDefaultAutoSelectFamily(true)\nnet.setDefaultAutoSelectFamilyAttemptTimeout(750)\n```\n\nLong enough for a real connection to win the race, short enough that a genuinely dead address still fails quickly.\n\n## Why it is worth knowing\n\nThe failure looks like a total outage from inside your own tooling and like perfect health from everywhere else. I nearly went looking at DNS, then at the web server, then at the firewall.\n\nThe thing to try first is the same request through `curl`. If `curl` succeeds and Node does not, you are not looking at a network problem. You are looking at a difference between two clients, and the list of those differences is short.\n\nI hit this twice in one week: once in a deploy preflight and once in the smoke test that runs after it. Both were checks whose entire job is to report whether something is healthy, and both reported the opposite of the truth. A check that cries wolf gets ignored, and an ignored check is worse than no check at all.",null,[10,11],"Engineering","Debugging","2026-07-31T08:00:00+03:00",2,"Node's Happy Eyeballs gives the first address family 250ms. Against a host with a dead IPv6 record, fetch hangs while curl connects first try.",{"html":16,"toc":17},"\u003Ch2 id=\"the-report-that-was-wrong\">The report that was wrong\u003C\u002Fh2>\n\u003Cp>A post-deploy smoke script came back with twenty failures, all identical:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>\u003Cdiv class=\"code-block\" role=\"region\" aria-label=\"Code block\">\n  \u003Cdiv class=\"code-block__bar\">\n    \u003Cspan class=\"code-block__lang eyebrow\">text\u003C\u002Fspan>\n    \u003Cbutton\n      class=\"code-block__copy\"\n      type=\"button\"\n      aria-label=\"Copy code\"\n      data-code-index=\"0\"\n    >\n      \u003Cspan class=\"code-block__copy-text\">Copy\u003C\u002Fspan>\n    \u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n  \u003Cdiv class=\"code-block__content\">\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan>GET \u002F          TypeError: fetch failed\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>GET \u002Fservices  TypeError: fetch failed\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>GET \u002Fblog      TypeError: fetch failed\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\n  \u003Ctextarea class=\"sr-only\" aria-hidden=\"true\" data-code-raw=\"GET \u002F          TypeError: fetch failed\nGET \u002Fservices  TypeError: fetch failed\nGET \u002Fblog      TypeError: fetch failed\n\" tabindex=\"-1\" readonly>\u003C\u002Ftextarea>\n\u003C\u002Fdiv>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Twenty out of twenty-one checks. The site was down.\u003C\u002Fp>\n\u003Cp>Except it was not. \u003Ccode>curl\u003C\u002Fcode> returned 200 for every one of those URLs, from the same machine, seconds apart.\u003C\u002Fp>\n\u003Ch2 id=\"two-address-families-one-of-them-a-black-hole\">Two address families, one of them a black hole\u003C\u002Fh2>\n\u003Cp>The host resolved to both an IPv4 address and an IPv6 address. The IPv4 worked. The IPv6 accepted nothing.\u003C\u002Fp>\n\u003Cp>\u003Ccode>curl\u003C\u002Fcode> implements Happy Eyeballs: try both families, keep whichever answers first. It reached the site on the first attempt every time.\u003C\u002Fp>\n\u003Cp>Node implements Happy Eyeballs too. \u003Ccode>autoSelectFamily\u003C\u002Fcode> has been on by default since Node 20, and I checked that it was on. So why did it hang?\u003C\u002Fp>\n\u003Cp>The answer is the timeout sitting next to it. \u003Ccode>autoSelectFamilyAttemptTimeout\u003C\u002Fcode> defaults to 250 milliseconds. That is how long Node gives the first address before it moves to the next one. If the IPv4 connection has not completed inside that window, Node starts the IPv6 attempt as well, the IPv6 attempt goes nowhere, and the request sits there until the whole thing times out.\u003C\u002Fp>\n\u003Cp>250ms is not much for a TLS handshake to a shared host on another continent.\u003C\u002Fp>\n\u003Ch2 id=\"the-fix-is-two-lines\">The fix is two lines\u003C\u002Fh2>\n\u003Cpre>\u003Ccode class=\"language-js\">\u003Cdiv class=\"code-block\" role=\"region\" aria-label=\"Code block\">\n  \u003Cdiv class=\"code-block__bar\">\n    \u003Cspan class=\"code-block__lang eyebrow\">js\u003C\u002Fspan>\n    \u003Cbutton\n      class=\"code-block__copy\"\n      type=\"button\"\n      aria-label=\"Copy code\"\n      data-code-index=\"1\"\n    >\n      \u003Cspan class=\"code-block__copy-text\">Copy\u003C\u002Fspan>\n    \u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n  \u003Cdiv class=\"code-block__content\">\u003Cpre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">import\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> net \u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">from\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\"> 'node:net'\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">net.\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">setDefaultAutoSelectFamily\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">net.\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#6F42C1;--shiki-dark:#B392F0\">setDefaultAutoSelectFamilyAttemptTimeout\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\">750\u003C\u002Fspan>\u003Cspan style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003C\u002Fdiv>\n  \u003Ctextarea class=\"sr-only\" aria-hidden=\"true\" data-code-raw=\"import net from 'node:net'\n\nnet.setDefaultAutoSelectFamily(true)\nnet.setDefaultAutoSelectFamilyAttemptTimeout(750)\n\" tabindex=\"-1\" readonly>\u003C\u002Ftextarea>\n\u003C\u002Fdiv>\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Long enough for a real connection to win the race, short enough that a genuinely dead address still fails quickly.\u003C\u002Fp>\n\u003Ch2 id=\"why-it-is-worth-knowing\">Why it is worth knowing\u003C\u002Fh2>\n\u003Cp>The failure looks like a total outage from inside your own tooling and like perfect health from everywhere else. I nearly went looking at DNS, then at the web server, then at the firewall.\u003C\u002Fp>\n\u003Cp>The thing to try first is the same request through \u003Ccode>curl\u003C\u002Fcode>. If \u003Ccode>curl\u003C\u002Fcode> succeeds and Node does not, you are not looking at a network problem. You are looking at a difference between two clients, and the list of those differences is short.\u003C\u002Fp>\n\u003Cp>I hit this twice in one week: once in a deploy preflight and once in the smoke test that runs after it. Both were checks whose entire job is to report whether something is healthy, and both reported the opposite of the truth. A check that cries wolf gets ignored, and an ignored check is worse than no check at all.\u003C\u002Fp>\n",[18,21,24,27],{"id":19,"text":20,"level":13},"the-report-that-was-wrong","The report that was wrong",{"id":22,"text":23,"level":13},"two-address-families-one-of-them-a-black-hole","Two address families, one of them a black hole",{"id":25,"text":26,"level":13},"the-fix-is-two-lines","The fix is two lines",{"id":28,"text":29,"level":13},"why-it-is-worth-knowing","Why it is worth knowing",[31,33,42,52,62],{"slug":4,"title":5,"excerpt":6,"body":7,"cover":8,"tags":32,"publishedAt":12,"readingTime":13,"seoTitle":5,"seoDescription":14},[10,11],{"slug":34,"title":35,"excerpt":36,"body":37,"cover":8,"tags":38,"publishedAt":40,"readingTime":13,"seoTitle":35,"seoDescription":41},"cascade-layers-declare-the-order-first","Cascade layers only work if you declare the order first","A class selector in my own layer was losing to a type selector in a framework reset. The layer order I had carefully declared was being ignored, and nothing said so.","## A rule that could not lose, losing\n\nI had a button styled like this:\n\n```css\n@layer app {\n  .btn--accept {\n    background-color: var(--accent);\n    color: var(--on-accent);\n  }\n}\n```\n\nThe background was right. The text colour was not. Same rule, same block, one property applying and the other not.\n\nThe winner turned out to be this, from a framework reset:\n\n```css\n@layer framework-core.reset {\n  button { color: inherit }\n}\n```\n\nA type selector beating a class selector. Which is correct, if that layer sorts after mine.\n\n## Position is fixed at first mention\n\nA cascade layer's position is decided the first time its name appears, and nothing later can move it. A statement like this sets the order:\n\n```css\n@layer framework-core, framework-utilities, app, framework-final;\n```\n\nbut only if the browser reads it before it has met any of those names. If `app` has already been seen, that statement cannot pull it earlier. It appends the names it has not seen yet, behind the ones it has.\n\nMy ordering file said exactly the right thing. It was imported from a plugin, and the global stylesheet that declares `app` was emitted before it, so by the time the browser read my statement, `app` was already at the front and every framework layer landed behind it. The declared order and the real order were reverses of each other, and nothing reported it.\n\n## The fix is boring\n\nLoad the ordering file first. Not first among your own stylesheets. First, before anything that might declare a layer.\n\nFor me that meant putting it at the top of the global CSS array as well as leaving the plugin import alone. A repeated layer statement naming layers that already exist is a no-op, so having it twice costs nothing and keeps the order right if somebody reshuffles that array later.\n\n## How to see the real order\n\nThe declared order is not the thing to inspect. The emitted order is. In the browser:\n\n```js\n[...document.styleSheets]\n  .flatMap((s) => { try { return [...s.cssRules] } catch { return [] } })\n  .filter((r) => r.constructor.name === 'CSSLayerStatementRule')\n  .map((r) => [...r.nameList])\n```\n\nOr read the built CSS and find which layer block appears first. If your layer is not where you assumed, every specificity argument you have had with that framework was settled before you started.\n\n## What I take from it\n\nLayers are not a way to win. They are a way to declare who wins, and the declaration has to arrive before the contestants do.",[10,39],"CSS","2026-07-30T08:00:00+03:00","A CSS cascade layer takes its position from the first time its name is seen. Declare the order after that and the browser ignores you, silently.",{"slug":43,"title":44,"excerpt":45,"body":46,"cover":8,"tags":47,"publishedAt":49,"readingTime":50,"seoTitle":44,"seoDescription":51},"isr-route-rules-might-be-doing-nothing","Your ISR route rules might be doing nothing","I moved a Nuxt site to a plain Node server and every route rule silently stopped working. Nothing warned me, nothing failed, and the site got slower forever.","## The rules that were comments\n\nI moved a Nuxt site from a managed platform to a plain Node server last week. Same build, same route rules, same everything. It worked immediately, which is the part that should have worried me.\n\nThe config had this:\n\n```ts\nrouteRules: {\n  '\u002F': { isr: 60 },\n  '\u002Fblog\u002F**': { isr: 30 },\n}\n```\n\nIncremental static regeneration: serve a cached render, refresh it in the background. On the old host that is exactly what happened.\n\nOn the new one, none of it happened. Not one of those rules did anything, and nothing anywhere said so.\n\n## isr belongs to the platform, not to Nitro\n\n`isr` is not implemented by Nitro. It is read by Nitro's platform presets. The Vercel preset translates it into Vercel's revalidation config, the Netlify preset into Netlify's, the Cloudflare preset into Cloudflare's. Those presets are the entire implementation.\n\nThe `node-server` preset has no handler for it. It does not warn. It does not fail the build. The key is simply never read, so the rule is a comment with a colon in it.\n\nThe site kept working. It re-rendered every page and re-fetched the CMS on every single request, and served correct HTML while doing it. That is the failure I find most expensive: not broken, just quietly doing the slow thing forever.\n\n## swr is the one that works anywhere\n\n`swr` is Nitro's own stale-while-revalidate, implemented in its generic cache layer, so it works on every preset:\n\n```ts\nrouteRules: {\n  '\u002F': { swr: 60 },\n  '\u002Fblog\u002F**': { swr: 30 },\n}\n```\n\nNitro normalises `swr: 60` into `cache: { swr: true, maxAge: 60 }`. A request inside the window is served from cache. The first request after it gets the stale copy while a fresh render happens behind it.\n\nThe numbers did not change. Only the key did.\n\n## Two things that came with it\n\nThe cache has to live somewhere. Nitro's default is memory, which sounds fine until you remember that Passenger idles an app out after a few minutes of no traffic and spawns a fresh process on the next request. On a quiet site a memory cache is empty almost every time somebody arrives, which is the exact traffic pattern a new site has. It writes to disk now.\n\nThe cache is also keyed by route, not by build. Deploy without clearing it and you serve the last release's HTML as though it were current, for as long as the max age says. The deploy step removes the cache directory before restarting.\n\n## How to check yours\n\nGrep your config for `isr`, then check which preset you actually build with. If those two do not agree, your caching is decorative.\n\n```bash\ngrep -rn \"isr:\" nuxt.config.ts\n```\n\nI added a test that fails if `isr` ever comes back to that file. One line, against a mistake that costs nothing to make and gives no signal at all.",[10,48],"Nuxt","2026-07-29T08:00:00+03:00",3,"Nitro reads isr route rules only in its platform presets. On a plain Node server they are silently ignored, and the site re-renders everything forever.",{"slug":53,"title":54,"excerpt":55,"body":56,"cover":8,"tags":57,"publishedAt":59,"readingTime":13,"seoTitle":60,"seoDescription":61},"thin-controllers-fat-actions","Thin Controllers, Fat Actions","Controllers should orchestrate, not implement. Moving business logic into dedicated Action classes keeps your Laravel codebase readable, testable, and change-friendly.","## The problem with fat controllers\n\nIt starts innocently enough. A `store` method that validates a request, creates a record, sends an email, and fires an event. Twenty lines. Readable enough.\n\nSix months later that same method is 120 lines. It handles three different code paths depending on the authenticated user's role. The email is conditional on a feature flag. There is a try-catch that swallows exceptions in one branch but re-throws in another.\n\nThis is not a failure of discipline. It is a failure of structure. The controller has no natural boundary to push back against accumulation.\n\n## Actions as the answer\n\nAn Action is a single-purpose PHP class with one public method — `execute` or `handle` — that does exactly one thing. It receives the data it needs as arguments. It performs the work. It returns a result or throws an exception.\n\n```php\nclass SubscribeAction\n{\n    public function execute(string $email, string $locale = 'en'): void\n    {\n        \u002F\u002F create subscriber, send email — nothing else\n    }\n}\n```\n\nThe controller becomes a thin orchestrator:\n\n```php\npublic function store(SubscribeRequest $request, SubscribeAction $action)\n{\n    $action->execute($request->validated('email'));\n    return response()->json(['message' => 'Check your inbox.'], 202);\n}\n```\n\n## What you gain\n\n**Testability.** You can unit-test the Action without booting the HTTP layer. Pass in the arguments, assert the side effects.\n\n**Reusability.** The same Action can be called from a controller, a console command, a job, or a Livewire component.\n\n**Readability.** The controller tells you *what* happens at this endpoint. The Action tells you *how*. The separation of concerns is explicit in the file structure.\n\n**Change safety.** When the subscribe flow changes — say, you add a source tracking field — you change the Action. The controller and its tests are untouched.\n\nThink of Actions as the verbs of your domain. Name them accordingly: `SubscribeAction`, `PublishPostAction`, `ConfirmSubscriptionAction`. Your codebase becomes a vocabulary for the business problem it solves.",[10,58],"Laravel","2026-07-28T15:43:56+03:00","Thin Controllers, Fat Actions in Laravel","Moving business logic into dedicated Action classes keeps Laravel codebases readable and testable. A practical guide by David Kimani.",{"slug":63,"title":64,"excerpt":65,"body":66,"cover":8,"tags":67,"publishedAt":69,"readingTime":13,"seoTitle":64,"seoDescription":70},"why-i-always-start-with-the-data-model","Why I Always Start With the Data Model","Before I write a single line of application code, I sit with the schema. Here is why the data model is the most important design decision you will make on any project.","## The schema is the truth\n\nEvery application is, at its core, a set of transformations applied to data. The UI is a view. The API is an interface. But the schema is the truth of what your system believes about the world.\n\nWhen I take on a new project, the first artifact I produce is not a wireframe or a component tree. It is an entity-relationship diagram — even a rough one sketched on paper. I want to understand what *things* the system knows about, how those things relate to each other, and what facts it needs to record.\n\n## Constraints are features\n\nA `NOT NULL` constraint, a `UNIQUE` index, a foreign key — these are not implementation details. They are business rules encoded directly into the database engine. The further a constraint lives from the data, the easier it is to violate.\n\nI have inherited too many codebases where validation lived exclusively in the application layer, and the database was a free-for-all. Orphaned records, duplicate emails, null values in columns the business assumed were always populated. Schema-level constraints prevent entire categories of bugs before you write a single test.\n\n## Design for queries, not inserts\n\nThe shape of your data should be driven by how you need to read it, not just how you write it. If you will always load a post with its tags, model that relationship explicitly. If you will frequently filter subscribers by status, index that column.\n\nStart with the queries the product requires. Work backwards to the schema that makes those queries fast and simple. The insert path is almost always easy; it is the read path that reveals whether your model is right.\n\n## Change is inevitable — make it safe\n\nNo schema survives contact with production unchanged. The question is not whether you will migrate, but whether your migrations are reversible and your team trusts the process.\n\nI write every migration with a `down()` method. I run migrations against a copy of production data before deploying. I version-control every schema change alongside the application code that depends on it.\n\nThe data model is the hardest thing to change once a system is live. Getting it right early pays back more than anything else you can do at the start of a project.",[10,68],"Product","2026-07-24T15:43:56+03:00","Before writing application code, David Kimani starts with the schema. Here is why the data model is the most important design decision on any project."]