<?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>Forem: Solvo Dev Notes</title>
    <description>The latest articles on Forem by Solvo Dev Notes (@solvodevnotes).</description>
    <link>https://forem.com/solvodevnotes</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3934444%2F48ed2d1b-1f28-4ac0-8d20-124d166ce248.png</url>
      <title>Forem: Solvo Dev Notes</title>
      <link>https://forem.com/solvodevnotes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/solvodevnotes"/>
    <language>en</language>
    <item>
      <title>My AI agent can't click "Sign up for an API key" — so I built a business-day endpoint with no signup</title>
      <dc:creator>Solvo Dev Notes</dc:creator>
      <pubDate>Sun, 17 May 2026 05:08:27 +0000</pubDate>
      <link>https://forem.com/solvodevnotes/my-ai-agent-cant-click-sign-up-for-an-api-key-so-i-built-a-business-day-endpoint-with-no-signup-2hlc</link>
      <guid>https://forem.com/solvodevnotes/my-ai-agent-cant-click-sign-up-for-an-api-key-so-i-built-a-business-day-endpoint-with-no-signup-2hlc</guid>
      <description>&lt;p&gt;I've been running an autonomous coding agent for a while now. It writes code, opens PRs, and occasionally needs to compute things like "what's the SLA deadline if a ticket lands today and we promise resolution in 5 business days?"&lt;/p&gt;

&lt;p&gt;Two problems showed up immediately, and they're the reason this post exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: LLMs are genuinely bad at date arithmetic
&lt;/h2&gt;

&lt;p&gt;This isn't a hot take, it's measurable. A 2025 arXiv study ("Lost in Time: Clock and Calendar Understanding Challenges in Multimodal LLMs") found that models do okay on &lt;em&gt;popular&lt;/em&gt; holidays — likely because the answer is memorized — but accuracy "diminishes substantially for lesser-known or arithmetically demanding queries (e.g., 153rd day), indicating that performance does not transfer well to offset-based reasoning."&lt;/p&gt;

&lt;p&gt;That matches what I see in practice. Ask a model "add 5 business days to 2026-05-15, skipping weekends" and you'll get a confident, plausible, and frequently off-by-one answer. Business-day math is exactly the kind of deterministic, rule-based calculation where "close enough" is just wrong: weekends, observed-holiday shifting, and counting conventions all compound. The well-known fix is to stop asking the model to be a calculator and hand the calculation to a deterministic tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: every deterministic tool wants me to sign up first
&lt;/h2&gt;

&lt;p&gt;So I went looking for a hosted business-day API. They exist and several are good. But here's the wall my agent hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API Ninjas Working Days&lt;/strong&gt; — requires an account and an &lt;code&gt;X-Api-Key&lt;/code&gt; header on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;workingdays.org&lt;/strong&gt; — requires an account; runs on a monthly quota with profiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;timeanddate.com Business Days Calculator&lt;/strong&gt; — no permanent free tier; access starts at $299, or a 3-month trial.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be fair: most of these &lt;em&gt;do&lt;/em&gt; offer a usable free tier. The blocker isn't the price. The blocker is that &lt;strong&gt;every one of them requires creating an account and provisioning a key before the first request will succeed.&lt;/strong&gt; A human does that in two minutes. An autonomous agent cannot — there's no inbox to confirm, no dashboard to click, no human in the loop. The signup step is where the automation dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I shipped the boring version: OpenWorkdays
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://openworkdays.vercel.app" rel="noopener noreferrer"&gt;OpenWorkdays&lt;/a&gt; is a zero-signup, zero-key business-day date API. One anonymous GET, JSON back. MIT, zero-dependency, &lt;a href="https://github.com/SolvoHQ/openworkdays" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add business days:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://openworkdays.vercel.app/api/businessdays?start=2026-05-15&amp;amp;days=5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Count business days between two dates:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://openworkdays.vercel.app/api/businessdays?start=2026-05-15&amp;amp;end=2026-05-29"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Is this date a business day?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://openworkdays.vercel.app/api/businessdays?date=2026-05-16"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One endpoint, three modes (&lt;code&gt;add&lt;/code&gt;, &lt;code&gt;diff&lt;/code&gt;, &lt;code&gt;is&lt;/code&gt;). You can configure the weekend (&lt;code&gt;weekend=sat,sun&lt;/code&gt;) and pass your own holiday list (&lt;code&gt;holidays=2026-05-25,2026-07-03&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest scope (read this before you adopt it)
&lt;/h2&gt;

&lt;p&gt;I'd rather you not be surprised in production, so plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Date-only, pure UTC arithmetic.&lt;/strong&gt; No time-of-day, no DST, no timezones. If you need "5 business days from 3pm Berlin time," this is the wrong tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No built-in country holiday database.&lt;/strong&gt; There is no magic &lt;code&gt;country=US&lt;/code&gt;. You supply the holidays you care about via &lt;code&gt;holidays=YYYY-MM-DD,...&lt;/code&gt;. This is a deliberate trade: it keeps the service stateless and free of a holiday-data dependency, but it means &lt;em&gt;you&lt;/em&gt; own the holiday calendar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best-effort, per-instance rate limit.&lt;/strong&gt; It's free and unauthenticated; don't build a billing system on it without a fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the common agent/backend case — "skip weekends and these N holidays I already know about" — that scope is exactly enough, and the determinism is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  For LLM clients: there's an MCP server too
&lt;/h2&gt;

&lt;p&gt;If your agent speaks Model Context Protocol, you can skip HTTP plumbing entirely. There's a remote MCP server (Streamable HTTP, stateless JSON-RPC 2.0, one &lt;code&gt;businessdays&lt;/code&gt; tool), also zero-signup:&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;"mcpServers"&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;"openworkdays"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://openworkdays.vercel.app/api/mcp"&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;Drop that into Claude Desktop or Cursor and the model can call business-day math instead of guessing at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the right shape
&lt;/h2&gt;

&lt;p&gt;The general principle: if a task is deterministic and the model is unreliable at it, delegate to a deterministic tool. The under-discussed corollary is that &lt;em&gt;the tool also has to be reachable by the agent&lt;/em&gt;. An API behind a signup wall is, from an autonomous agent's perspective, not a tool — it's a tool with a human dependency bolted on.&lt;/p&gt;

&lt;p&gt;OpenWorkdays is intentionally small and intentionally honest about its limits. If it saves you one off-by-one SLA bug, it did its job. Feedback and issues welcome on the repo.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>api</category>
      <category>opensource</category>
    </item>
    <item>
      <title>No-signup link unfurl for AI agents (an agent can't do a signup)</title>
      <dc:creator>Solvo Dev Notes</dc:creator>
      <pubDate>Sun, 17 May 2026 01:54:38 +0000</pubDate>
      <link>https://forem.com/solvodevnotes/no-signup-link-unfurl-for-ai-agents-an-agent-cant-do-a-signup-6h3</link>
      <guid>https://forem.com/solvodevnotes/no-signup-link-unfurl-for-ai-agents-an-agent-cant-do-a-signup-6h3</guid>
      <description>&lt;p&gt;If you write code that calls links — a bot, a crawler, a RAG ingest job, an autonomous agent — you eventually need the metadata behind a URL: title, description, preview image, site name. The annoying part isn't parsing Open Graph tags. It's that almost every managed API that does this for you wants you to sign up first.&lt;/p&gt;

&lt;p&gt;That requirement is fine for a human. It is a wall for an agent. An autonomous script has no inbox to confirm, no dashboard to click, no card to enter. "Just grab an API key" is a step a human does once and an agent cannot do at all.&lt;/p&gt;

&lt;p&gt;OpenUnfurl is a small answer to that specific problem: a public link-unfurl endpoint with no account, no API key, one GET.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://openunfurl.vercel.app/api/unfurl?url=https://github.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns clean JSON:&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resolvedUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GitHub · Change is constant. GitHub keeps you ahead."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Join the world's most widely adopted, AI-powered developer platform..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://images.ctfassets.net/.../GH-Homepage-Universe-img.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"siteName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GitHub"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"favicon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.githubassets.com/favicons/favicon.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"oembed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fetchedAt"&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-05-17T01:53:00.977Z"&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;From JS:&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://openunfurl.vercel.app/api/unfurl?url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meta&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// meta.title, meta.description, meta.image, meta.siteName, meta.favicon&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole integration. No SDK, no env var, no onboarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a hosted unfurl instead of feeding raw HTML to the model
&lt;/h2&gt;

&lt;p&gt;A reasonable objection: agents already have web access — why not let the LLM read the page itself?&lt;/p&gt;

&lt;p&gt;Because raw HTML is an expensive way to find four fields. The pattern people keep landing on in 2026 is the "token tax": an average page is ~200KB of HTML wrapping ~10KB of actual text, so dumping the DOM into a context window means paying premium per-token rates to process navigation, inline styles, cookie banners, and tracking scripts. Reported numbers are not small — clean structured output instead of raw HTML cuts extraction token usage on the order of 60% and up, and removing the markup noise also reduces misreads and hallucinated fields. There is a real trade-off worth stating plainly: for &lt;em&gt;simple static pages&lt;/em&gt;, a lightweight purpose-built parser is cheaper and more predictable than an LLM-based extraction step. OpenUnfurl is exactly that lightweight parser — it does the metadata extraction server-side and hands your agent four clean fields instead of a page of soup.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's also a remote MCP server
&lt;/h2&gt;

&lt;p&gt;If your agent speaks Model Context Protocol, you don't need the REST shape at all. There's a remote MCP endpoint at the same origin, Streamable HTTP, stateless JSON-RPC 2.0:&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;"mcpServers"&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;"openunfurl"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://openunfurl.vercel.app/api/mcp"&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;One tool, &lt;code&gt;unfurl&lt;/code&gt;, input &lt;code&gt;{"url":"..."}&lt;/code&gt;. Smoke test it with a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://openunfurl.vercel.app/api/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"unfurl","arguments":{"url":"https://example.com"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shape is deliberate. Streamable HTTP is the current MCP transport standard (HTTP+SSE was deprecated in mid-2025), it's stateless so it runs fine on scale-to-zero serverless, and clients like Claude can connect to authless remote servers without an OAuth dance. No key here either — same reason as the REST endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;This is v0.1 and the scope is narrow on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static HTML only.&lt;/strong&gt; It fetches and parses the HTML the server returns. No headless browser, no JS execution. A client-rendered SPA that ships an empty &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and paints metadata with JavaScript will come back thin or empty. That's a known gap, not a bug being hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best-effort rate limiting.&lt;/strong&gt; Per-instance, per-IP, best-effort. Not a contractual quota. Don't point a firehose at it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSRF-guarded.&lt;/strong&gt; It refuses internal/private address targets. Public URLs only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need full headless render parity, the funded incumbents (Microlink, OpenGraph.io, and similar) do that well — and most of them gate the usable free tier behind a signup or an API key. OpenUnfurl is not trying to beat them on render fidelity. The seam it's filling is different: no signup, instant, agent-native, for the static-HTML majority of links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;Zero-dependency Node serverless, MIT licensed: &lt;a href="https://github.com/SolvoHQ/openunfurl" rel="noopener noreferrer"&gt;https://github.com/SolvoHQ/openunfurl&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both surfaces are live now — the &lt;code&gt;curl&lt;/code&gt; above works as you read this. If your agent needs link metadata and you don't want to teach it to sign up for something, point it at the endpoint and move on.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>opensource</category>
      <category>api</category>
    </item>
    <item>
      <title>AbortController: the cancellation bugs most JavaScript devs ship</title>
      <dc:creator>Solvo Dev Notes</dc:creator>
      <pubDate>Sat, 16 May 2026 07:54:17 +0000</pubDate>
      <link>https://forem.com/solvodevnotes/abortcontroller-the-cancellation-bugs-most-javascript-devs-ship-34kn</link>
      <guid>https://forem.com/solvodevnotes/abortcontroller-the-cancellation-bugs-most-javascript-devs-ship-34kn</guid>
      <description>&lt;p&gt;AbortController has been in every browser and Node release that matters for years now, and most code I review still gets it subtly wrong. Not "doesn't work" wrong — "works until it doesn't" wrong: ghost requests overwriting fresh state, timeouts that can't be told apart from user cancels, &lt;code&gt;AbortError&lt;/code&gt; logged as if the sky fell, leaked listeners that never fire.&lt;/p&gt;

&lt;p&gt;None of these throw in the demo. They surface in production under a flaky network and a fast-clicking user. Here are the patterns that actually hold up, with the 2026 API surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Cancelling fetch — and why &lt;code&gt;AbortError&lt;/code&gt; is not an error
&lt;/h2&gt;

&lt;p&gt;The mechanics are easy. The mistake is what you do in &lt;code&gt;catch&lt;/code&gt;.&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;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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/search?q=hello&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;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="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;err&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&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="c1"&gt;// not a failure — we caused it&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// a real failure — surface it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// elsewhere, e.g. the user typed another character:&lt;/span&gt;
&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most common bug: a blanket &lt;code&gt;catch&lt;/code&gt; that pipes &lt;em&gt;every&lt;/em&gt; rejection into your error UI. When you abort an in-flight &lt;code&gt;fetch&lt;/code&gt;, the promise rejects. If you don't special-case that rejection, cancelling a request renders an error toast for an action the user deliberately took. Abort is a normal control-flow outcome, not an exception condition.&lt;/p&gt;

&lt;p&gt;Two precision points people get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;controller.abort()&lt;/code&gt; with no argument&lt;/strong&gt; rejects &lt;code&gt;fetch&lt;/code&gt; with a &lt;code&gt;DOMException&lt;/code&gt; whose &lt;code&gt;name&lt;/code&gt; is &lt;code&gt;"AbortError"&lt;/code&gt;. That's the case the snippet above handles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;controller.abort(reason)&lt;/code&gt;&lt;/strong&gt; rejects &lt;code&gt;fetch&lt;/code&gt; with &lt;em&gt;that reason&lt;/em&gt; instead. If you pass &lt;code&gt;abort(new Error('user navigated'))&lt;/code&gt;, your &lt;code&gt;err.name === 'AbortError'&lt;/code&gt; check won't match and you'll re-throw it. If you use custom reasons, branch on &lt;code&gt;controller.signal.aborted&lt;/code&gt; / inspect &lt;code&gt;signal.reason&lt;/code&gt;, don't pattern-match the name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A controller is &lt;strong&gt;single-use&lt;/strong&gt;. Once &lt;code&gt;abort()&lt;/code&gt; has been called, the signal is permanently aborted ("sticky") — handing that same signal to a new &lt;code&gt;fetch&lt;/code&gt; aborts it immediately. One operation, one fresh &lt;code&gt;AbortController&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; instead of the setTimeout dance
&lt;/h2&gt;

&lt;p&gt;The hand-rolled version is everywhere and it leaks:&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;// Don't do this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&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;t&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="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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="k"&gt;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// BUG: if the fetch resolves in 200ms, this timer still fires 4.8s later&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="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// easy to forget; without it the timer keeps a ref alive&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the built-in:&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AbortSignal.timeout(ms)&lt;/code&gt; returns a signal that aborts itself after &lt;code&gt;ms&lt;/code&gt;. No timer handle to clean up, nothing to forget. Two properties worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It aborts with a &lt;code&gt;DOMException&lt;/code&gt; named &lt;strong&gt;&lt;code&gt;"TimeoutError"&lt;/code&gt;&lt;/strong&gt;, &lt;em&gt;not&lt;/em&gt; &lt;code&gt;"AbortError"&lt;/code&gt;. This is a feature: you can finally tell "the server was too slow" apart from "the user hit cancel" in one &lt;code&gt;catch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The clock is &lt;strong&gt;active time, not wall-clock&lt;/strong&gt;. It pauses while the document is in the back/forward cache or a worker is suspended, so a backgrounded tab won't spuriously time out the moment it's restored.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TimeoutError&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="nf"&gt;showRetry&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// slow server&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&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="c1"&gt;// user cancelled&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;
  
  
  3. &lt;code&gt;AbortSignal.any()&lt;/code&gt; — timeout &lt;em&gt;and&lt;/em&gt; user-cancel, correctly
&lt;/h2&gt;

&lt;p&gt;The real-world requirement is almost always "abort if the user cancels &lt;strong&gt;or&lt;/strong&gt; if it takes too long." People reach for nested controllers and a &lt;code&gt;setTimeout&lt;/code&gt;. Don't. Compose:&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;function&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&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;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&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="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// caller's user-cancel signal&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&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;AbortSignal.any([...signals])&lt;/code&gt; returns a signal that aborts as soon as &lt;strong&gt;any&lt;/strong&gt; input aborts. &lt;code&gt;signal.reason&lt;/code&gt; is set to the reason of whichever one fired first — so you can still distinguish a &lt;code&gt;TimeoutError&lt;/code&gt; from a user &lt;code&gt;AbortError&lt;/code&gt; after combining:&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;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userCancel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TimeoutError&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="cm"&gt;/* it was the 8s timeout */&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&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="cm"&gt;/* it was the user */&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 sharp edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If &lt;strong&gt;any&lt;/strong&gt; input signal is &lt;em&gt;already&lt;/em&gt; aborted when you call &lt;code&gt;AbortSignal.any()&lt;/code&gt;, the combined signal comes back already aborted. That's correct behavior, but it means you must build the combined signal &lt;em&gt;per attempt&lt;/em&gt;, not once and reuse it — same single-use rule as a controller.&lt;/li&gt;
&lt;li&gt;On Node, &lt;code&gt;AbortSignal.any()&lt;/code&gt; had a history of memory leaks when a long-lived signal accumulated many short-lived dependents (nodejs/node #54614, #57584); fixes landed progressively through the v26.x line. The practical guidance hasn't changed: keep the composed signal scoped to one operation and let it get collected, rather than wiring thousands of per-request signals into one process-lifetime parent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The React &lt;code&gt;useEffect&lt;/code&gt; + StrictMode trap
&lt;/h2&gt;

&lt;p&gt;This is where most people actually meet AbortController, and where the bug is the most expensive because it looks like it works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// cleanup: cancel the in-flight request&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this exact shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fresh controller inside the effect body.&lt;/strong&gt; Not in a ref shared across runs, not module scope. Each effect run owns its own controller because an aborted one is dead forever (point 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;abort()&lt;/code&gt; in the cleanup function.&lt;/strong&gt; When &lt;code&gt;userId&lt;/code&gt; changes, React runs cleanup &lt;em&gt;then&lt;/em&gt; re-runs the effect. Without the abort, a slow response for the old &lt;code&gt;userId&lt;/code&gt; can land &lt;em&gt;after&lt;/em&gt; the new one and overwrite correct state with stale data. This is the classic search/autocomplete race, and AbortController is the fix — not a debounce (a debounce only narrows the window).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter &lt;code&gt;AbortError&lt;/code&gt; before &lt;code&gt;setState&lt;/code&gt;.&lt;/strong&gt; The aborted request rejects; if you don't filter it you'll call &lt;code&gt;setError&lt;/code&gt; for a cancellation, and possibly set state on an unmounted component.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On &lt;strong&gt;StrictMode in development&lt;/strong&gt; (React 18 and 19): the effect runs, cleans up, and runs again — on purpose. You'll see the first request show as cancelled (red) in the network panel. That is not a bug to silence; it's StrictMode proving your cleanup works. The React team's position is explicit: this is expected, and the resolution is correct cleanup + a fresh controller per run — exactly the code above. It does not fire twice in production builds. Disabling StrictMode to "fix" it just hides the broken-cleanup class of bugs until production finds them for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Node: the same signal, far past fetch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbortSignal&lt;/code&gt; is the cancellation currency across Node core, not just HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-removing event listeners.&lt;/strong&gt; The &lt;code&gt;signal&lt;/code&gt; option on &lt;code&gt;addEventListener&lt;/code&gt; (and Node's &lt;code&gt;EventEmitter&lt;/code&gt;/&lt;code&gt;EventTarget&lt;/code&gt;) removes the listener when the signal aborts. One &lt;code&gt;abort()&lt;/code&gt; tears down a whole group of listeners — no bookkeeping, no matching &lt;code&gt;removeEventListener&lt;/code&gt;:&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;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nx"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// both listeners gone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Awaitable timers.&lt;/strong&gt; &lt;code&gt;timers/promises&lt;/code&gt; honors a signal, so a delay becomes cancellable:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:timers/promises&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&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="c1"&gt;// cancelled before the 10s elapsed&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;strong&gt;Streams.&lt;/strong&gt; &lt;code&gt;fs.createReadStream(path, { signal })&lt;/code&gt; and the stream iterator helpers (&lt;code&gt;.map&lt;/code&gt;, &lt;code&gt;.filter&lt;/code&gt;, &lt;code&gt;stream.compose&lt;/code&gt;, &lt;code&gt;events.on&lt;/code&gt;) all accept a &lt;code&gt;signal&lt;/code&gt; and destroy the stream on abort — clean cancellation of a large file transfer instead of letting it run to completion after the client already disconnected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cooperative cancellation between awaits.&lt;/strong&gt; When you write your own async function, the signal can be passed through but nothing checks it for you between steps. &lt;code&gt;signal.throwIfAborted()&lt;/code&gt; is the one-liner that does:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;throwIfAborted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// bail at the boundary if already aborted&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&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;It throws &lt;code&gt;signal.reason&lt;/code&gt; if aborted and does nothing otherwise — the idiomatic way to add abort checkpoints to a loop without hand-rolling &lt;code&gt;if (signal.aborted) throw ...&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One &lt;code&gt;AbortController&lt;/code&gt; per operation. Signals are sticky and single-use — never reuse one after &lt;code&gt;abort()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In every &lt;code&gt;catch&lt;/code&gt; touching cancellable work, ignore the cancellation: &lt;code&gt;if (err.name === 'AbortError') return;&lt;/code&gt; — abort is control flow, not an error.&lt;/li&gt;
&lt;li&gt;Reach for &lt;code&gt;AbortSignal.timeout(ms)&lt;/code&gt; over &lt;code&gt;setTimeout&lt;/code&gt; + &lt;code&gt;abort()&lt;/code&gt;. It can't leak a timer, and &lt;code&gt;TimeoutError&lt;/code&gt; is distinguishable from &lt;code&gt;AbortError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Combine concerns with &lt;code&gt;AbortSignal.any([...])&lt;/code&gt;, built fresh per attempt; read &lt;code&gt;signal.reason&lt;/code&gt; to learn which one fired.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;useEffect&lt;/code&gt;: fresh controller in the body, &lt;code&gt;abort()&lt;/code&gt; in cleanup, filter &lt;code&gt;AbortError&lt;/code&gt; before &lt;code&gt;setState&lt;/code&gt;. StrictMode's double-run is the test, not the bug.&lt;/li&gt;
&lt;li&gt;In Node, pass &lt;code&gt;signal&lt;/code&gt; everywhere it's accepted (listeners, &lt;code&gt;timers/promises&lt;/code&gt;, streams) and use &lt;code&gt;signal.throwIfAborted()&lt;/code&gt; for checkpoints in your own async code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get these six right and the entire class of "stale response clobbered fresh state" / "cancel shows an error" / "timer leaked" bugs disappears.&lt;/p&gt;




&lt;p&gt;I built an interactive version of every pattern here so you can run them in the browser, abort them mid-flight, and watch exactly what each &lt;code&gt;catch&lt;/code&gt; sees: &lt;strong&gt;&lt;a href="https://solvo-devnotes.vercel.app" rel="noopener noreferrer"&gt;https://solvo-devnotes.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsolvo-devnotes.goatcounter.com%2Fcount%3Fp%3D%2Fdevto-abortcontroller%26t%3Ddevto-article" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fsolvo-devnotes.goatcounter.com%2Fcount%3Fp%3D%2Fdevto-abortcontroller%26t%3Ddevto-article" width="1" height="1"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
      <category>react</category>
    </item>
  </channel>
</rss>
