<?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: Anton Shubin</title>
    <description>The latest articles on DEV Community by Anton Shubin (@anton_shubin_f26b3cb5a572).</description>
    <link>https://dev.to/anton_shubin_f26b3cb5a572</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3723221%2F6e1f3495-1f01-437b-818f-34863c61017a.webp</url>
      <title>DEV Community: Anton Shubin</title>
      <link>https://dev.to/anton_shubin_f26b3cb5a572</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anton_shubin_f26b3cb5a572"/>
    <language>en</language>
    <item>
      <title>How to hire a developer to take over your AI-built app</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Fri, 06 Nov 2026 11:56:50 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/how-to-hire-a-developer-to-take-over-your-ai-built-app-56mj</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/how-to-hire-a-developer-to-take-over-your-ai-built-app-56mj</guid>
      <description>&lt;p&gt;If you built your MVP with Lovable, Bolt, Cursor or Claude, you probably got further than anyone expected, and now you need a person to take it on. This post is for you: how to tell a senior developer from a pasted proposal, and how to hand over the keys without handing over the house. There is a checklist inside to score the proposals you already have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are not hiring someone to write code: the AI already did that. You are hiring someone who can read code they didn't write and tell you what is unsafe.&lt;/li&gt;
&lt;li&gt;Pass on a proposal with two or more red flags, such as a leftover placeholder, a wrong name, a diagnosis that fits any app or an offer of free work.&lt;/li&gt;
&lt;li&gt;Shortlist the ones with proof: a live app on your stack, one specific insight, an honest gap and a dated, priced first step.&lt;/li&gt;
&lt;li&gt;Start with a small paid audit and read-only access, keep every account in your name, and keep the right to stop any week.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To hire a developer to take over your AI-built app, look for someone who can&lt;br&gt;
read code they didn't write, not someone who promises to write more of it. Skip&lt;br&gt;
the proposals that could have been sent to anyone. Shortlist the ones that show&lt;br&gt;
a live app on your stack and one specific insight about yours. Then start with a&lt;br&gt;
small paid audit before you hand over production.&lt;/p&gt;

&lt;p&gt;This post is for a founder who built an MVP with Lovable, Bolt, Cursor or Claude&lt;br&gt;
and now needs a senior developer to continue. I am one of those developers: I&lt;br&gt;
take over apps like yours, and I sell the audit this post recommends. I'll say&lt;br&gt;
where that matters, and every check below works on me too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are you really hiring for?
&lt;/h2&gt;

&lt;p&gt;The AI wrote your code fast. What it rarely does on its own is decide what is&lt;br&gt;
safe, test it, and plan for the day something breaks. That is the job you are&lt;br&gt;
hiring for.&lt;/p&gt;

&lt;p&gt;AI-built apps tend to break in the same few places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database access rules.&lt;/strong&gt; On Supabase these are Row Level Security policies.
A table with only a read policy looks fine in a demo. Then updates and deletes
can quietly change zero rows, and inserts fail with an error the generated
code often never checks. The user sees "Saved!", and nothing was saved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret keys in the browser.&lt;/strong&gt; A key meant for the server ends up in the code
every visitor downloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments the browser decides.&lt;/strong&gt; The page marks an order as paid, instead of
your server hearing it from Stripe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No safety net.&lt;/strong&gt; No staging copy, no backups you have restored once, no
tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the person you want reads first, judges second and writes third. They keep&lt;br&gt;
what works, which is most of it. A developer whose first idea is "let's rewrite&lt;br&gt;
it from scratch" is offering to throw away the one thing you have that already&lt;br&gt;
works, and to bill you for the privilege.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Frewrite.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Frewrite.webp" title="Rewrite is a plan, not an insight" alt="The Roll Safe meme: a man taps his temple with a knowing smile. Captions: &amp;quot;Can't have bugs in your app&amp;quot; and &amp;quot;If I rewrite your app&amp;quot;" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are still deciding whether you need a person at all, I wrote about&lt;br&gt;
&lt;a href="https://dev.to/why-not-just-ai"&gt;when an AI subscription is enough&lt;/a&gt; and when it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the red flags in a proposal?
&lt;/h2&gt;

&lt;p&gt;Wherever you post the job (Upwork, LinkedIn, a founder community, a friend of a&lt;br&gt;
friend), proposals arrive fast and often look the same. A lot of them are&lt;br&gt;
written by AI too, which is a little funny for a job about cleaning up after AI.&lt;/p&gt;

&lt;p&gt;Here is what should make you close a proposal before the second paragraph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A template placeholder is still in it.&lt;/strong&gt; A &lt;code&gt;[client name]&lt;/code&gt; or a
&lt;code&gt;[your project]&lt;/code&gt; that nobody filled in. If they didn't read their own message,
they won't read your code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your name is wrong, or someone else signed it.&lt;/strong&gt; It was pasted from the last
job, and the one before that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It opens by feeling your pain.&lt;/strong&gt; "I understand how frustrating it is when
your app…" That is everyone's opening now. It costs nothing and tells you
nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The diagnosis fits any app.&lt;/strong&gt; "Your app needs refactoring, a better
architecture and performance work." They haven't seen your app. I haven't
either, and that sentence is still true of it. It's true of mine too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reads like generated text.&lt;/strong&gt; Bold headings in a two-paragraph message,
emoji bullets, every paragraph the same length, a "Week 1 / Week 2" plan for
code they haven't opened. Someone who lets a model write even the hello will
let it write your review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rate is far below a senior rate.&lt;/strong&gt; A senior job at a junior price means
the person is not senior, or will not stay. You pay the difference later, in
rework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It offers free work or a discount.&lt;/strong&gt; "I'll fix the first bug for free", or a
special price just for you. It reads as need, not confidence. And free work
comes without a contract, so nothing says the code is yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An agency writes as one person.&lt;/strong&gt; "I" in the first paragraph, "our team" in
the third. Agencies are fine. Hiding who will do the work is not, so ask who
writes the code and talk to that person.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A video link is the whole reply.&lt;/strong&gt; A short video about your project can be
great. A video instead of a written answer means you can't skim it, compare it
or forward it to a technical friend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Fplaceholder.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Fplaceholder.webp" title="Bespoke, hand-crafted, copy-pasted" alt="The Is This a Pigeon meme: a man points at a butterfly labelled &amp;quot;Dear&amp;quot;, then an unfilled client-name placeholder, and asks, &amp;quot;Is this a proposal written for me?&amp;quot;" width="800" height="718"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What do green flags look like?
&lt;/h2&gt;

&lt;p&gt;Good proposals are usually shorter. They show you something instead of telling&lt;br&gt;
you something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A live app on your stack.&lt;/strong&gt; A link in the first lines that you can open,
built on what you use: Supabase, Next.js, Stripe, whatever yours is. One or
two that work beat a list of twenty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One specific insight from real work.&lt;/strong&gt; A sentence a chatbot would not write
from your job post. "Check your security rules" is generic. "Your table
probably has a read policy and no update policy, so edits do nothing and no
error shows" is someone who has been bitten by it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An honest gap.&lt;/strong&gt; "I haven't worked in Lovable itself, but what it exports is
React and Supabase, which I know well." A person who admits one gap is easier
to believe about everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dated, priced first step.&lt;/strong&gt; "A written audit by Friday, for a fixed price.
Read-only access first. Nothing reaches production without staging. You can
stop any week." The risk sits on their side of the table, not yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One question only an expert would ask.&lt;/strong&gt; Everyone asks how many users you
have. "Does your Stripe webhook mark an order as paid, or does the page?" or
"Do your admins and your customers share one users table?" shows they are
already looking at the parts that break.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Score the proposal you received
&lt;/h3&gt;

&lt;p&gt;Count the flags, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two or more red flags: pass, however good the price looks.&lt;/li&gt;
&lt;li&gt;One red flag: ask about it before you pay for anything.&lt;/li&gt;
&lt;li&gt;No red flags and three or more green ones: offer this candidate a paid first
step.&lt;/li&gt;
&lt;li&gt;No red flags, but little proof yet: ask the three questions below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a rule of thumb, not a court ruling. A tired senior can send a clumsy&lt;br&gt;
message, and a polished one can come from someone who has never shipped. But the&lt;br&gt;
flags are cheap to check, and they sort a long list fast.&lt;/p&gt;

&lt;p&gt;The red and green flags above also work as a checklist you can tick for a proposal you received.&lt;/p&gt;

&lt;p&gt;Try it on antonshubin.com: &lt;a href="https://antonshubin.com/blog/hire-developer-take-over-ai-built-app?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=hire-developer-take-over-ai-built-app#proposal-checklist" rel="noopener noreferrer"&gt;https://antonshubin.com/blog/hire-developer-take-over-ai-built-app?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=hire-developer-take-over-ai-built-app#proposal-checklist&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which three questions should you ask every candidate?
&lt;/h2&gt;

&lt;p&gt;A short call or a written reply is enough for these. Listen for specifics.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"What would you check first in my app, and why?"&lt;/strong&gt; A good answer names the
things that hurt users: data access rules, secret keys, payments, backups.
Features and styling come later. "I'd refactor the components" is a weak
answer, and "I'd rewrite it" is a red flag with a clock on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Show me something you shipped that still runs. What broke in it?"&lt;/strong&gt; You
want a live link and a real failure story. Someone who has never broken
production has never run one. I have a whole
&lt;a href="https://dev.to/blog/ai-agent-merged-a-strangers-pull-request"&gt;blog category for my own scars&lt;/a&gt;,
so I can't pretend otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"If we stop after the first week, what do I keep?"&lt;/strong&gt; The right answer is
"everything": the code in your repository, the accounts in your name, and the
notes written down. Anyone who hesitates here is planning to be hard to
leave.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why does a paid audit beat a free call?
&lt;/h2&gt;

&lt;p&gt;A free call is good for one thing: checking that you can talk to each other. I&lt;br&gt;
offer one myself, and I'd keep it for exactly that. But nobody on a free call&lt;br&gt;
has read your code, so everything said there is a guess wrapped in a pitch.&lt;/p&gt;

&lt;p&gt;A small paid audit is different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They read your code.&lt;/strong&gt; The advice is about your app, not apps in general.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You get a written report you keep.&lt;/strong&gt; It lists what is solid, what is risky
and what to fix first. You can hand it to any developer, including a cheaper
one than the person who wrote it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You see how they work before the big contract.&lt;/strong&gt; Did it arrive on the date
they named? Is it in plain words? Did the risky things come first, or the easy
ones?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It costs a little, and it is the cheapest way to find out what you are actually&lt;br&gt;
buying. Yes, this is the part where I mention that I sell one: my&lt;br&gt;
&lt;a href="https://dev.to/catalog/codebase-health-audit"&gt;code and security audit&lt;/a&gt; has a fixed price,&lt;br&gt;
quoted before any work starts, and gives you a written report and a call to walk&lt;br&gt;
you through it. If you hire someone else, ask them for the same shape of first&lt;br&gt;
step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Ffree-call.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Ffree-call.webp" title="Predictions are free for a reason" alt="The Drake meme in two panels. Drake turns away from &amp;quot;A free call where they guess&amp;quot; and points approvingly at &amp;quot;A paid audit where they read your code&amp;quot;" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you run the first week safely?
&lt;/h2&gt;

&lt;p&gt;You don't need to be technical for any of this. You need to be the person who&lt;br&gt;
holds the keys.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep every account in your name.&lt;/strong&gt; The repository, the hosting, the
database, Stripe, the domain, the email. Invite the developer as a member;
never share your password. When the work ends, you remove one member and
there is nothing to recover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give read-only access first.&lt;/strong&gt; An audit needs to read the code and the
database settings, not change them. Write access comes after you have read
the report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make your own backup before anything changes.&lt;/strong&gt; Export the database and
keep a copy of the code somewhere the developer can't delete it. It is the
cheapest insurance you will ever buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging before production.&lt;/strong&gt; A second copy of the app, with test data,
where every change lands first. If you don't have one, building it is a fine
first task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay in small steps, and keep the right to stop any week.&lt;/strong&gt; Ask for a short
written update every week: what changed, what's next, what is risky. A week
with nothing you can see is a reason to stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change the keys when someone leaves.&lt;/strong&gt; Any secret key the developer saw
gets replaced. It's not distrust; it's how you close the door.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Fkeys.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fhire-developer-take-over-ai-built-app%2Fkeys.webp" title="Trust is earned one key at a time" alt="The Change My Mind meme: a man sits at a table with a sign that reads &amp;quot;Give a new developer read-only access first. Change my mind&amp;quot;" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Got a proposal in your inbox right now? Run it through the&lt;br&gt;
checklist above and ask the three questions. And if you'd&lt;br&gt;
like me to be one of the candidates, my&lt;br&gt;
&lt;a href="https://dev.to/catalog/codebase-health-audit"&gt;code and security audit&lt;/a&gt; is the first step I'd&lt;br&gt;
offer you: a written report of what is solid, what is risky and what to fix&lt;br&gt;
first, before anyone touches production. If you'd rather talk it through first,&lt;br&gt;
book a &lt;a href="https://dev.to/catalog/strategy-call"&gt;one-hour strategy session&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://antonshubin.com/blog/hire-developer-take-over-ai-built-app?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=hire-developer-take-over-ai-built-app" rel="noopener noreferrer"&gt;antonshubin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>developers</category>
      <category>freelance</category>
      <category>softwaredevelopment</category>
      <category>startup</category>
    </item>
    <item>
      <title>Deno's development is ending, so I moved my SaaS starter to Node and Bun</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:51:04 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/denos-development-is-ending-so-i-moved-my-saas-starter-to-node-and-bun-280g</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/denos-development-is-ending-so-i-moved-my-saas-starter-to-node-and-bun-280g</guid>
      <description>&lt;p&gt;Deno's team is joining Cloudflare, and the runtime's development ends after one more year of fixes. Seed, my SaaS starter, ran on Deno and Fresh, so I moved it to run on Deno, Node and Bun. This post is the honest account: what was hard, what wasn't, and the slowdown I shipped on the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;Deno.*&lt;/code&gt; calls were not the hard part. Serving files, Deno types in public signatures and Fresh's internals were.&lt;/li&gt;
&lt;li&gt;I replaced Fresh by copying its API: 419 of 423 responses came out identical, and every page shipped less JavaScript.&lt;/li&gt;
&lt;li&gt;My first release of that layer made the slider post block input for 119 ms against Fresh's 51.5 ms. CI missed it: none of its pages has an island.&lt;/li&gt;
&lt;li&gt;Don't rewrite. Put each &lt;code&gt;Deno.*&lt;/code&gt; behind a small adapter, run the build on Node and Bun in CI, and diff every response before and after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Seed&lt;/strong&gt;: Live: &lt;a href="https://seedsaas.com" rel="noopener noreferrer"&gt;Open the live demo&lt;/a&gt; · Code: &lt;a href="https://github.com/spy4x/seed" rel="noopener noreferrer"&gt;github.com/spy4x/seed&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To migrate from Deno to Node and Bun, you don't rewrite your app. You keep it on&lt;br&gt;
web standards (&lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;Request&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;), put every &lt;code&gt;Deno.*&lt;/code&gt; call behind a&lt;br&gt;
small adapter, and replace what only runs on Deno, such as Fresh. I did exactly&lt;br&gt;
that for &lt;a href="https://dev.to/blog/seed-saas-starter"&gt;Seed&lt;/a&gt;, the SaaS starter every new project of&lt;br&gt;
mine starts from. The pull requests that finished it merged on 4 November 2026.&lt;/p&gt;

&lt;p&gt;This post is for a developer or a tech lead with a Deno codebase who read the&lt;br&gt;
news and now has to decide: stay, fork or move. Short version of the worry "my&lt;br&gt;
code is full of &lt;code&gt;Deno.*&lt;/code&gt;": those calls were the easy part. It shows what was&lt;br&gt;
really tied to Deno in a tested codebase, what replaced each piece, and the&lt;br&gt;
mistake I shipped on the way.&lt;/p&gt;
&lt;h2&gt;
  
  
  What happened to Deno?
&lt;/h2&gt;

&lt;p&gt;On 9 October 2026, Ryan Dahl wrote that&lt;br&gt;
&lt;a href="https://deno.com/blog/cloudflare" rel="noopener noreferrer"&gt;Deno is joining Cloudflare&lt;/a&gt;. The runtime gets&lt;br&gt;
another year of monthly releases with bug fixes and security updates, and then:&lt;br&gt;
"After that year we will end our development of the Deno runtime." Deno stays&lt;br&gt;
open source, and others are welcome to continue it.&lt;/p&gt;

&lt;p&gt;The same post says Deno Deploy keeps running for six months and then shuts down,&lt;br&gt;
and JSR keeps running on Cloudflare's infrastructure.&lt;br&gt;
&lt;a href="https://blog.cloudflare.com/deno-joins-cloudflare/" rel="noopener noreferrer"&gt;Cloudflare's own post&lt;/a&gt; says&lt;br&gt;
the team will merge Deno's &lt;code&gt;celld&lt;/code&gt; into Cloudflare's &lt;code&gt;workerd&lt;/code&gt; runtime. Neither&lt;br&gt;
post says what happens to Fresh, Deno's web framework.&lt;/p&gt;

&lt;p&gt;So nothing breaks tomorrow. Your app runs the same today as it did yesterday.&lt;br&gt;
What changes is the future: a bug or a security hole found after that year may&lt;br&gt;
never be fixed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fpanik.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fpanik.webp" title="The emotional arc of reading the announcement" alt="The Panik Kalm Panik meme in three rows. &amp;quot;Deno's development ends after one more year of fixes&amp;quot;: Panik. &amp;quot;My servers already speak fetch, Request and Response&amp;quot;: Kalm. &amp;quot;My website runs on Fresh&amp;quot;: Panik" width="640" height="881"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why move a starter that worked?
&lt;/h2&gt;

&lt;p&gt;Seed is not one app. It is the starting point of every product I build, client&lt;br&gt;
MVPs included. A runtime that stops getting fixes under it would sit under all&lt;br&gt;
of them.&lt;/p&gt;

&lt;p&gt;So the goal was not "leave Deno". It was "run unchanged on Deno, Node and Bun".&lt;br&gt;
Deno stays both a runtime Seed supports and its toolchain: &lt;code&gt;deno fmt&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;deno lint&lt;/code&gt; and &lt;code&gt;deno test&lt;/code&gt; still run every check. If Deno finds a new home,&lt;br&gt;
nothing is lost. If it doesn't, the switch is a start file, not a rewrite.&lt;/p&gt;
&lt;h2&gt;
  
  
  "But my code is full of &lt;code&gt;Deno.*&lt;/code&gt;"
&lt;/h2&gt;

&lt;p&gt;That is the usual worry, and it was mine too. Here is the honest answer: the&lt;br&gt;
&lt;code&gt;Deno.*&lt;/code&gt; calls were the easy part.&lt;/p&gt;

&lt;p&gt;Reading an environment variable, serving a request, opening a file: each has an&lt;br&gt;
obvious twin on Node, and Bun implements Node's &lt;code&gt;node:fs&lt;/code&gt; and &lt;code&gt;node:net&lt;/code&gt;&lt;br&gt;
(&lt;a href="https://bun.com/docs/runtime/nodejs-compat" rel="noopener noreferrer"&gt;Bun's compatibility list&lt;/a&gt;). So one&lt;br&gt;
small adapter covers both. This is the start file that runs Seed's website on&lt;br&gt;
Node and Bun, trimmed of its port check. Deno runs the same built server with&lt;br&gt;
&lt;code&gt;deno serve&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ServeHandler&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="s2"&gt;@spy4x/platform/server/serve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shutdownSignal&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="s2"&gt;@spy4x/platform/server/shutdown-signal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;exitProcess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;processEnv&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="s2"&gt;@spy4x/platform/server/process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;processEnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`MPA_HOST`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`0.0.0.0`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;processEnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`MPA_PORT`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;8000&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;built&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServeHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`./server.js`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&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;server&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;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;built&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;port&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="nf"&gt;shutdownSignal&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finished&lt;/span&gt;
&lt;span class="nf"&gt;exitProcess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;processEnv&lt;/code&gt; reads &lt;code&gt;Deno.env&lt;/code&gt; on Deno and &lt;code&gt;process.env&lt;/code&gt; on Node and Bun.&lt;/li&gt;
&lt;li&gt;The built server is a plain &lt;code&gt;fetch&lt;/code&gt; handler: a &lt;code&gt;Request&lt;/code&gt; in, a &lt;code&gt;Response&lt;/code&gt;
out. That is the web standard doing the heavy lifting.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;serve()&lt;/code&gt; listens on whichever runtime it finds itself on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shutdownSignal()&lt;/code&gt; is an &lt;code&gt;AbortSignal&lt;/code&gt; that fires on SIGINT or SIGTERM, so
requests in flight finish before the process exits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Even the Redis client needed nothing. It already talked over a &lt;code&gt;connectTcp&lt;/code&gt;&lt;br&gt;
adapter in my shared library: &lt;code&gt;Deno.connect&lt;/code&gt; on Deno, &lt;code&gt;node:net&lt;/code&gt; on Node and&lt;br&gt;
Bun. I tested it on Valkey 8.1, Node 26.13 and Bun 1.4.4.&lt;/p&gt;
&lt;h3&gt;
  
  
  So what was hard?
&lt;/h3&gt;

&lt;p&gt;Four things, and only one of them is a function call:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default arguments that call &lt;code&gt;Deno.*&lt;/code&gt;.&lt;/strong&gt; A function with a default like
&lt;code&gt;fs = denoFs&lt;/code&gt; looks harmless until Node imports it, and there is no &lt;code&gt;Deno&lt;/code&gt; to
call. The defaults now pick the running runtime's version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deno types in exported signatures.&lt;/strong&gt; &lt;code&gt;Deno.Signal&lt;/code&gt; sat in public types, so
a Node project could not even type-check my library. It became &lt;code&gt;OsSignal&lt;/code&gt;, a
list of the same 36 names, with a type test in both directions so the two can
never drift apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error classes.&lt;/strong&gt; Deno throws &lt;code&gt;Deno.errors.IsADirectory&lt;/code&gt;; Node throws an
error with &lt;code&gt;code: "EISDIR"&lt;/code&gt;. One &lt;code&gt;fsError&lt;/code&gt; and &lt;code&gt;isFsError&lt;/code&gt; pair maps both.
The Node adapter is imported lazily, so &lt;code&gt;deno check&lt;/code&gt; doesn't pull in Node's
types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serving files.&lt;/strong&gt; Range requests and ETags took the most care. A test caught
&lt;code&gt;bytes=10-&lt;/code&gt; on a 10-byte file returning 200 instead of 416: a range that
starts past the end of the file asks for nothing, and the right answer is
"Range Not Satisfiable". A reviewer caught that a weak ETag must never
satisfy &lt;code&gt;If-Range&lt;/code&gt;, because the
&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.5" rel="noopener noreferrer"&gt;HTTP standard&lt;/a&gt;
compares it with the strong comparison only.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final check was blunt: &lt;code&gt;deno bundle&lt;/code&gt; of the 77 &lt;code&gt;@spy4x/*&lt;/code&gt; imports Seed uses&lt;br&gt;
produced one &lt;code&gt;.mjs&lt;/code&gt; file, and it ran directly on Node and Bun, with no&lt;br&gt;
&lt;code&gt;package.json&lt;/code&gt;. All of that shipped as release 1.116.0 of my shared library.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you replace Fresh without rewriting every route?
&lt;/h2&gt;

&lt;p&gt;Fresh was the real tie. It is&lt;br&gt;
&lt;a href="https://github.com/denoland/fresh" rel="noopener noreferrer"&gt;Deno's own framework&lt;/a&gt;, built by the same&lt;br&gt;
company, and I had built Seed's public website on it. That is the mistake I own&lt;br&gt;
here: I put a framework owned by the runtime's company under the one project&lt;br&gt;
everything else starts from.&lt;/p&gt;

&lt;p&gt;I didn't rewrite the routes. I wrote a small layer that copies Fresh 2's API:&lt;br&gt;
&lt;code&gt;App&lt;/code&gt;, &lt;code&gt;ctx.render&lt;/code&gt;, &lt;code&gt;createDefine&lt;/code&gt;, &lt;code&gt;fsRoutes&lt;/code&gt;. It renders with Preact, builds&lt;br&gt;
with Vite and runs on web standards. It is&lt;br&gt;
&lt;a href="https://github.com/spy4x/preact-components/pull/1187" rel="noopener noreferrer"&gt;&lt;code&gt;@spy4x/preact-server&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
1,459 lines of source and 919 lines of tests, part of my open-source component&lt;br&gt;
library. Because the API matched, Seed's route files barely changed. Seed's side&lt;br&gt;
of the switch, &lt;a href="https://github.com/spy4x/seed/pull/815" rel="noopener noreferrer"&gt;spy4x/seed#815&lt;/a&gt;, touched&lt;br&gt;
128 files and deleted more than it added: +562, −908.&lt;/p&gt;
&lt;h3&gt;
  
  
  Did anything change for the visitor?
&lt;/h3&gt;

&lt;p&gt;To find out, I requested every route on both builds and compared the answers&lt;br&gt;
(&lt;a href="https://github.com/spy4x/seed/issues/623" rel="noopener noreferrer"&gt;spy4x/seed#623&lt;/a&gt;). 419 of 423&lt;br&gt;
responses were identical. Two of the four differences were one video's&lt;br&gt;
modification time and ETag. The other two were the malformed path &lt;code&gt;/%E0%A4%A&lt;/code&gt;:&lt;br&gt;
Fresh answered with a bare 500, the new layer answers with the site's 404 page.&lt;/p&gt;

&lt;p&gt;The pages also got lighter. Gzipped JavaScript per page, Lighthouse mobile, 22&lt;br&gt;
pages, median of 3 runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;New layer&lt;/th&gt;
&lt;th&gt;Fresh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home&lt;/td&gt;
&lt;td&gt;24.0 KB&lt;/td&gt;
&lt;td&gt;32.1 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A blog post with island&lt;/td&gt;
&lt;td&gt;29.9 KB&lt;/td&gt;
&lt;td&gt;38.0 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;8.6 KB&lt;/td&gt;
&lt;td&gt;19.4 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every page scored 98–100 on both builds. Of the 112 end-to-end tests, 110 passed&lt;br&gt;
on the first run. The two that failed tested Fresh's internals: an island's file&lt;br&gt;
name and the CSP nonce.&lt;/p&gt;
&lt;h3&gt;
  
  
  What's better, and what's missing?
&lt;/h3&gt;

&lt;p&gt;Two things got better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No nonce in the CSP.&lt;/strong&gt; The layer puts island props in a JSON block, not an
inline script, so the Content-Security-Policy needs no nonce. This very site
still runs on Fresh, and its CSP code reads Fresh's private nonce. That is
exactly the kind of internal I don't want to depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safer redirects.&lt;/strong&gt; &lt;code&gt;ctx.redirect()&lt;/code&gt; keeps &lt;code&gt;/\host&lt;/code&gt; and &lt;code&gt;/&amp;lt;tab&amp;gt;/host&lt;/code&gt; on the
site
(&lt;a href="https://github.com/spy4x/preact-components/blob/6d5082cfa45b5ce19fbb3e6f351b8c2874e475de/server/app.test.tsx#L130-L137" rel="noopener noreferrer"&gt;the test&lt;/a&gt;).
Fresh lets both through to another host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And four things Fresh has that the layer doesn't: &lt;code&gt;csrf()&lt;/code&gt; middleware (a&lt;br&gt;
same-origin check from my shared library does the job), &lt;code&gt;_404&lt;/code&gt; and &lt;code&gt;_500&lt;/code&gt;&lt;br&gt;
routes, its own dev server (Vite runs development instead), and a per-route&lt;br&gt;
opt-out of the modulepreload header.&lt;/p&gt;
&lt;h2&gt;
  
  
  The scar: my replacement made the slider post slower than Fresh
&lt;/h2&gt;

&lt;p&gt;Seed's blog has a post with an interactive slider,&lt;br&gt;
&lt;a href="https://seedsaas.com/blog/welcome-to-the-seed-blog" rel="noopener noreferrer"&gt;the welcome post&lt;/a&gt;. On the&lt;br&gt;
first release of my layer, 13.11.0, that page blocked input for 119 ms. Fresh&lt;br&gt;
blocked it for 51.5 ms. That is Lighthouse 12.8.2, mobile, 6 alternating runs&lt;br&gt;
each. The page shipped less JavaScript and still blocked input more than twice&lt;br&gt;
as long.&lt;/p&gt;

&lt;p&gt;The cause: loading and hydrating every island ran as one long task. The browser&lt;br&gt;
could not answer a tap until the last island was awake.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fbike-fall.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fbike-fall.webp" title="Nobody did this to me but me" alt="The Bike Fall meme in three panels. A cyclist rides along: &amp;quot;Ship 13.11.0&amp;quot;. He pushes a stick into his own front wheel: &amp;quot;Hydrate every island in one task&amp;quot;. He lies on the ground: &amp;quot;119 ms of blocked input&amp;quot;" width="500" height="680"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix, in 13.11.1&lt;br&gt;
(&lt;a href="https://github.com/spy4x/preact-components/pull/1193" rel="noopener noreferrer"&gt;#1193&lt;/a&gt;), gives the&lt;br&gt;
browser a turn before each island:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;yieldToMain&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;scheduler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;scheduler&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It uses&lt;br&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield" rel="noopener noreferrer"&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
where the browser has it and falls back to &lt;code&gt;setTimeout(0)&lt;/code&gt;. The blocking time&lt;br&gt;
dropped to 2 ms. The design reviewer re-checked it over 7 runs: 5 ms against&lt;br&gt;
Fresh's 48 ms, and a Lighthouse score of 97 against 96.&lt;/p&gt;

&lt;p&gt;Why didn't CI catch it? Seed's CI does run a Lighthouse speed check. But none of&lt;br&gt;
the pages it measures has an island&lt;br&gt;
(&lt;a href="https://github.com/spy4x/seed/issues/816" rel="noopener noreferrer"&gt;spy4x/seed#816&lt;/a&gt;). A speed check only&lt;br&gt;
guards the pages you give it. If your most expensive page isn't in the list, its&lt;br&gt;
regression ships green.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Measure the page with the heaviest island, not the page that's easiest to&lt;br&gt;
measure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The trap: one line in &lt;code&gt;package.json&lt;/code&gt; broke the tests
&lt;/h2&gt;

&lt;p&gt;The last pull request, &lt;a href="https://github.com/spy4x/seed/pull/817" rel="noopener noreferrer"&gt;spy4x/seed#817&lt;/a&gt;,&lt;br&gt;
added the Node and Bun start files, their Docker images and a CI smoke test that&lt;br&gt;
builds the site and runs it on all three runtimes. 27 files, +768, −212.&lt;/p&gt;

&lt;p&gt;Two surprises came with it. Adding &lt;code&gt;"type": "module"&lt;/code&gt; to the root &lt;code&gt;package.json&lt;/code&gt;&lt;br&gt;
broke Playwright under Deno. The fix was to leave the field out: that file only&lt;br&gt;
hands &lt;code&gt;npm run&lt;/code&gt; and &lt;code&gt;bun run&lt;/code&gt; over to &lt;code&gt;deno task&lt;/code&gt;. And in CI, the new smoke test&lt;br&gt;
and the browser tests raced each other on &lt;code&gt;vapid.json&lt;/code&gt; and the build folders.&lt;br&gt;
The fix: the smoke test now runs in its own copy of the checkout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fdomino.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fdomino.webp" title="One line of JSON, one red pipeline" alt="The Domino Effect meme: a man tips the smallest domino, labelled &amp;quot;\&amp;quot;type\&amp;quot;: \&amp;quot;module\&amp;quot;&amp;quot;, towards the biggest one, labelled &amp;quot;Playwright under Deno&amp;quot;" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How did AI agents split the work?
&lt;/h2&gt;

&lt;p&gt;I didn't type most of this code. One coordinator session, an AI agent, started&lt;br&gt;
separate sessions for each lane: my shared library, the website layer in my&lt;br&gt;
component library, the Seed port, and landing the move off Fresh. Each worked in&lt;br&gt;
its own git worktree with its own pull request.&lt;/p&gt;

&lt;p&gt;An independent reviewer agent gated every merge. On changes a visitor can see, a&lt;br&gt;
design reviewer gated it too, in a real browser. They earned their place: the&lt;br&gt;
weak-ETag &lt;code&gt;If-Range&lt;/code&gt; bug was a reviewer's find, and the 13.11.0 slowdown was the&lt;br&gt;
design reviewer's measurement.&lt;/p&gt;

&lt;p&gt;One honest lesson. When a session stopped halfway, the coordinator first only&lt;br&gt;
passed its report on to me instead of carrying the work on. I had to point that&lt;br&gt;
out. Now a coordinator continues a stopped session's work itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fmonkey.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fmigrate-from-deno-to-node-and-bun%2Fmonkey.webp" title="Very helpful. Thank you for the report." alt="The Monkey Puppet meme: the puppet glances sideways, then looks away. Caption: &amp;quot;My coordinator after forwarding a half-done session's report&amp;quot;" width="800" height="666"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An agent also drafted this post. Every number in it comes from a merged pull&lt;br&gt;
request or from the measurements in&lt;br&gt;
&lt;a href="https://github.com/spy4x/seed/issues/623" rel="noopener noreferrer"&gt;spy4x/seed#623&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How tied to Deno is your project?
&lt;/h2&gt;

&lt;p&gt;Here is every tie I found, and what replaced it in Seed. The first eleven were&lt;br&gt;
Seed's; the last three Seed never had:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your project uses&lt;/th&gt;
&lt;th&gt;What replaced it in Seed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Deno.serve&lt;/code&gt; or &lt;code&gt;deno serve&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The servers export a standard &lt;code&gt;fetch&lt;/code&gt; handler. Deno still runs it with &lt;code&gt;deno serve&lt;/code&gt;; on Node and Bun a start file hands it to &lt;code&gt;serve()&lt;/code&gt; from &lt;code&gt;@spy4x/platform&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Deno.env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;processEnv&lt;/code&gt; from &lt;code&gt;@spy4x/platform&lt;/code&gt;: &lt;code&gt;Deno.env&lt;/code&gt; on Deno, &lt;code&gt;process.env&lt;/code&gt; on Node and Bun.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File APIs: &lt;code&gt;Deno.readFile&lt;/code&gt;, &lt;code&gt;Deno.stat&lt;/code&gt;, &lt;code&gt;Deno.open&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A small file interface with two versions: &lt;code&gt;Deno.*&lt;/code&gt; on Deno, &lt;code&gt;node:fs/promises&lt;/code&gt; on Node and Bun.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deno's error classes, such as &lt;code&gt;Deno.errors.NotFound&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;fsError&lt;/code&gt; and &lt;code&gt;isFsError&lt;/code&gt; pair that also knows Node's &lt;code&gt;code: "EISDIR"&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Deno.Signal&lt;/code&gt; or other &lt;code&gt;Deno&lt;/code&gt; types in exported signatures&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OsSignal&lt;/code&gt;, a list of the same 36 names, with a type test in both directions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default arguments that call &lt;code&gt;Deno.*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Defaults that pick the running runtime's version.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Deno.connect&lt;/code&gt;, such as a Redis client&lt;/td&gt;
&lt;td&gt;Nothing: the client already ran over a &lt;code&gt;connectTcp&lt;/code&gt; adapter, &lt;code&gt;Deno.connect&lt;/code&gt; on Deno and &lt;code&gt;node:net&lt;/code&gt; on Node and Bun.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@spy4x/preact-server&lt;/code&gt;, a new layer that copies Fresh 2's API.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsr:&lt;/code&gt; imports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deno bundle&lt;/code&gt; builds them into the server file, so Node and Bun never see a &lt;code&gt;jsr:&lt;/code&gt; import.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Deno.Command&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing: only the dev, deploy and media scripts use it, and they stay on Deno.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;deno test&lt;/code&gt;, &lt;code&gt;deno fmt&lt;/code&gt;, &lt;code&gt;deno lint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing: the toolchain stays Deno. &lt;code&gt;npm run&lt;/code&gt; and &lt;code&gt;bun run&lt;/code&gt; call the same &lt;code&gt;deno task&lt;/code&gt;s.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deno KV&lt;/td&gt;
&lt;td&gt;Never used: the data lives in Postgres and Valkey.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deno Deploy&lt;/td&gt;
&lt;td&gt;Never used: Seed runs in Docker Compose on any Linux host.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deno compile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never used: Seed ships Docker images.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you ticked Deno Deploy, start there: it has six months, not a year. If you&lt;br&gt;
ship single binaries with &lt;code&gt;deno compile&lt;/code&gt;, as I do for&lt;br&gt;
&lt;a href="https://dev.to/blog/building-mcp-servers-with-deno"&gt;my MCP servers&lt;/a&gt;, that is a decision of&lt;br&gt;
its own. Tick your own project's ties below:&lt;/p&gt;

&lt;p&gt;The table above also works as a checklist you can tick for your own project.&lt;/p&gt;

&lt;p&gt;Try it on antonshubin.com: &lt;a href="https://antonshubin.com/blog/migrate-from-deno-to-node-and-bun?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=migrate-from-deno-to-node-and-bun#deno-ties" rel="noopener noreferrer"&gt;https://antonshubin.com/blog/migrate-from-deno-to-node-and-bun?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=migrate-from-deno-to-node-and-bun#deno-ties&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you stay, fork or move?
&lt;/h2&gt;

&lt;p&gt;My answer: don't panic, and don't rewrite. You have a year of fixes. Use it to&lt;br&gt;
make the switch cheap, then decide when you have to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count your ties.&lt;/strong&gt; The table above is a start. Look hardest at your public
types and your default arguments: that is where Deno hides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put each &lt;code&gt;Deno.*&lt;/code&gt; behind a small adapter.&lt;/strong&gt; One function per need, with a
Deno version and a Node version. Bun takes the Node one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the build on Node and Bun in CI.&lt;/strong&gt; A smoke test that starts the server
on each runtime and requests a page catches the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace the framework last, by copying its API.&lt;/strong&gt; Then diff every response
before and after, so "it works" is a number, not a feeling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your heaviest page.&lt;/strong&gt; The one with the most islands, not the one
that's easiest to measure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your product runs on Deno and you'd like a calm plan instead of a panicked&lt;br&gt;
rewrite, that is what my&lt;br&gt;
&lt;a href="https://dev.to/catalog/codebase-health-audit"&gt;code and security audit&lt;/a&gt; is for: three days,&lt;br&gt;
from $1,500. It covers architecture, security and performance, and for a Deno&lt;br&gt;
codebase that includes every tie to Deno, ranked by risk, with what to move&lt;br&gt;
first and what to leave alone.&lt;/p&gt;

&lt;p&gt;And if you've already started your own move, tell me what bit you. I'd like to&lt;br&gt;
know if it was the &lt;code&gt;Deno.*&lt;/code&gt; calls or, like me, everything around them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://antonshubin.com/blog/migrate-from-deno-to-node-and-bun?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=migrate-from-deno-to-node-and-bun" rel="noopener noreferrer"&gt;antonshubin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>softwaredevelopment</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My app's tab kept jumping to the front: passkey autofill and Bitwarden</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:46:00 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/my-apps-tab-kept-jumping-to-the-front-passkey-autofill-and-bitwarden-4j6k</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/my-apps-tab-kept-jumping-to-the-front-passkey-autofill-and-bitwarden-4j6k</guid>
      <description>&lt;p&gt;A friend told me my app's tab kept jumping to the front by itself, and a few days later it did it to me in the middle of a YouTube video. This post is the hunt, from the wrong first guess to the timer in my own sign-in page, and the two lines in Bitwarden's code it kept triggering. It is for anyone who ships passkey sign-in, or who ever wondered whether a web page can switch your tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A sign-in page with passkey autofill restarted its waiting request every 4.5 minutes. With Bitwarden installed, every restart pulled the tab, and its window, to the front.&lt;/li&gt;
&lt;li&gt;Bitwarden's code (version 2026.10.0) switches to the tab and focuses its window when any passkey request it handles ends, a cancel included.&lt;/li&gt;
&lt;li&gt;The fix: start and renew passkey autofill only while document.hasFocus() is true. A renewal that falls due in the background waits for the focus event.&lt;/li&gt;
&lt;li&gt;My first guess, the "new version" banner, was wrong. The timer was the clue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Seed&lt;/strong&gt;: Live: &lt;a href="https://seedsaas.com" rel="noopener noreferrer"&gt;Open the live demo&lt;/a&gt; · Code: &lt;a href="https://github.com/spy4x/seed" rel="noopener noreferrer"&gt;github.com/spy4x/seed&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpasskey-autofill-bitwarden-tab-focus%2Fsign-in.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpasskey-autofill-bitwarden-tab-focus%2Fsign-in.webp" title="The innocent-looking page that kept grabbing the browser" alt="The sign-in page of the Seed demo at app.seedsaas.com: a Welcome back card with an email or username field and a password field, under a note that the demo resets every night" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your web app's tab keeps switching to the front by itself, look at your&lt;br&gt;
passkey autofill. Bitwarden's extension switches to the tab and focuses its&lt;br&gt;
window whenever a passkey request it handles ends, a cancel included, so a&lt;br&gt;
conditional request that restarts on a timer steals focus on every restart. The&lt;br&gt;
fix: start and renew passkey autofill only while &lt;code&gt;document.hasFocus()&lt;/code&gt; is true.&lt;br&gt;
Together, a timer and Bitwarden made the sign-in page of &lt;a href="https://dev.to/tools/seed"&gt;Seed&lt;/a&gt;, my&lt;br&gt;
SaaS starter, jump to the front every four and a half minutes, no matter what&lt;br&gt;
the person was doing in other tabs.&lt;/p&gt;

&lt;p&gt;This is a post in my &lt;strong&gt;Scars&lt;/strong&gt; series: things I got wrong, and what I changed&lt;br&gt;
after. It's for anyone who ships passkey sign-in, and for anyone who wondered&lt;br&gt;
whether a web page can steal your focus.&lt;/p&gt;
&lt;h2&gt;
  
  
  What happened?
&lt;/h2&gt;

&lt;p&gt;A friend sent me a message: the Seed demo had been open in a background tab, and&lt;br&gt;
it switched to itself, showing the "New version available" banner. "Forced focus&lt;br&gt;
is too much," he wrote. I agreed it would be too much, but I wasn't sure a page&lt;br&gt;
could even do that.&lt;/p&gt;

&lt;p&gt;The next day I was browsing Google Maps in Brave, and the Seed tab jumped to the&lt;br&gt;
front. Twice in ten minutes. Then I put on a YouTube video, and it kept&lt;br&gt;
happening, about every five minutes. Now I believed him.&lt;/p&gt;
&lt;h2&gt;
  
  
  Can a web page switch your tab by itself?
&lt;/h2&gt;

&lt;p&gt;Not on its own. Modern browsers don't let an ordinary tab bring itself to the&lt;br&gt;
front with &lt;code&gt;window.focus()&lt;/code&gt;. A service worker can focus a tab, but only while it&lt;br&gt;
handles a click on one of its notifications.&lt;/p&gt;

&lt;p&gt;I asked my coding agent to check what Seed actually ships: the live JavaScript&lt;br&gt;
bundle, the service worker and the web app manifest. None of them called&lt;br&gt;
&lt;code&gt;focus()&lt;/code&gt; on a window, opened one, or focused a tab outside a notification&lt;br&gt;
click. So the agent's first answer was "not our code". That was true of focus&lt;br&gt;
calls, and it was not the whole answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Was it the "New version available" banner?
&lt;/h2&gt;

&lt;p&gt;Since my friend saw the "New version available" banner, the banner was the first&lt;br&gt;
suspect. It lives in a screen-reader live region, so new text in it is&lt;br&gt;
announced. Maybe the browser or the desktop raised the tab on that announcement?&lt;/p&gt;

&lt;p&gt;I ran the test: a blank tab, the same live region, the same text after 15&lt;br&gt;
seconds, while I sat on another tab. Nothing moved. And when it happened to me&lt;br&gt;
again there was no new version at all. Wrong guess.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every five minutes, with no new version, is not an event. It's a timer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What runs every 4.5 minutes?
&lt;/h2&gt;

&lt;p&gt;So I asked the agent to search the app and its shared libraries for timers. It&lt;br&gt;
found one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** How long an autofill passkey request waits before it starts again with a fresh challenge. */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PASSKEY_AUTOFILL_RENEW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seed's sign-in page offers your passkeys right in the login field's autofill. To&lt;br&gt;
do that, it keeps a request waiting in the background:&lt;br&gt;
&lt;code&gt;navigator.credentials.get()&lt;/code&gt; with &lt;code&gt;mediation: "conditional"&lt;/code&gt;. The request&lt;br&gt;
carries a challenge from the server, and the challenge expires after five&lt;br&gt;
minutes. So every four and a half minutes the page cancelled the waiting request&lt;br&gt;
and started a new one with a fresh challenge.&lt;/p&gt;

&lt;p&gt;And my friend's tab? Its address was &lt;code&gt;/sign-in&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who actually switched the tab?
&lt;/h2&gt;

&lt;p&gt;Before fixing anything, I told the agent to prove it. It ran a clean Brave&lt;br&gt;
profile with a probe page that cancels and restarts a conditional passkey&lt;br&gt;
request every 8 seconds, while another tab is active. The probe tab stayed in&lt;br&gt;
the background. So neither Brave nor the request itself does it.&lt;/p&gt;

&lt;p&gt;The difference was my real profile, and in it, the Bitwarden extension. With its&lt;br&gt;
passkey setting on, which is the default, Bitwarden handles passkeys on web&lt;br&gt;
pages in place of the browser. Here are the two calls its background script&lt;br&gt;
(version 2026.10.0) makes, with its helpers inlined. They run in a &lt;code&gt;finally&lt;/code&gt;&lt;br&gt;
after every passkey request it handles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tabId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;highlighted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;windowId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;focused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Every request" includes one the page cancels. So every 4.5 minutes Seed&lt;br&gt;
cancelled its waiting request, Bitwarden finished handling it, switched to the&lt;br&gt;
tab and focused the window. If Brave sits behind another app, the whole window&lt;br&gt;
can come up.&lt;/p&gt;

&lt;p&gt;Bitwarden didn't run in the test profile: it only steps in with a signed-in&lt;br&gt;
vault, and I wasn't going to sign a test script into mine. So this step is read&lt;br&gt;
from Bitwarden's code, not reproduced. The code reads plainly, and it matches&lt;br&gt;
the timing.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you stop passkey autofill stealing focus?
&lt;/h2&gt;

&lt;p&gt;Start and renew the conditional passkey request only while &lt;code&gt;document.hasFocus()&lt;/code&gt;&lt;br&gt;
is true, and catch up on the &lt;code&gt;focus&lt;/code&gt; event. That was my mistake: Seed restarted&lt;br&gt;
a request in a tab nobody was looking at. Seed can't change Bitwarden, but it&lt;br&gt;
can stop poking it while you're away. The sign-in page now starts and renews the&lt;br&gt;
autofill request only while the page has focus. A renewal that falls due in the&lt;br&gt;
background waits, and runs the moment you come back, before you can pick a&lt;br&gt;
passkey with an expired challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waitForAutofill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;renewal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasFocus&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;resume&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;waitForAutofill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;globalThis&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="s2"&gt;focus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;renewal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;focus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resume&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;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;// ...start a new request, and renew it again in 4.5 minutes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tests drive the real sign-in view with a fake passkey browser and a fake&lt;br&gt;
clock. They check that the request renews on time with focus, stays untouched in&lt;br&gt;
the background, and starts in a tab opened in the background only once it gets&lt;br&gt;
focus. The fix is in&lt;br&gt;
&lt;a href="https://github.com/spy4x/seed/pull/610" rel="noopener noreferrer"&gt;Seed's pull request #610&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A timer in a background tab is not invisible.&lt;/strong&gt; Anything you start, cancel
or restart there can reach extensions you never heard of. If it doesn't need
to run while the person is away, don't run it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renew passkey autofill only while the page has focus.&lt;/strong&gt; If you use
conditional mediation with a short-lived challenge, check
&lt;code&gt;document.hasFocus()&lt;/code&gt; before you cancel and restart, and catch up on the
&lt;code&gt;focus&lt;/code&gt; event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Every five minutes" is the best clue you can get.&lt;/strong&gt; My first guess chased
an event that happened to be on screen. The period pointed straight at the
cause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you use Bitwarden and a site of yours renews passkey autofill on a timer,&lt;br&gt;
sign in to your vault, open the site in a background tab and wait one renewal&lt;br&gt;
period. If the tab jumps to the front, add the focus check.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://antonshubin.com/blog/passkey-autofill-bitwarden-tab-focus?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=passkey-autofill-bitwarden-tab-focus" rel="noopener noreferrer"&gt;antonshubin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bug</category>
      <category>debugging</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I tried SKILL.state in my coding agents and stopped after four days</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Fri, 30 Oct 2026 12:20:40 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/i-tried-skillstate-in-my-coding-agents-and-stopped-after-four-days-151b</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/i-tried-skillstate-in-my-coding-agents-and-stopped-after-four-days-151b</guid>
      <description>&lt;p&gt;A friend sent me the SKILL.state paper and later asked how it worked out. This is the answer: what I built from it, the four tasks I ran, and why I stopped. It is for anyone who runs coding agents on long tasks and wonders whether an explicit state file is worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SKILL.state swaps an agent's growing chat history for a state file and a fresh step each time. I ran my own version of it on real tasks and stopped after four days.&lt;/li&gt;
&lt;li&gt;The briefs were clean, but every run needed hand fixes, such as patches that failed the schema, invented timestamps, and a state folder that broke the linter.&lt;/li&gt;
&lt;li&gt;The problem it solves, context that grows until it is compacted, mostly went away when I moved to Opus 5.5: 2 of 48 units of work were compacted.&lt;/li&gt;
&lt;li&gt;The part worth keeping, a fresh agent per step with a self-contained brief, I already use without the state file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In late September I read a paper that promised to fix the thing that bothered me&lt;br&gt;
most about long agent tasks: the conversation grows until the agent forgets what&lt;br&gt;
it was doing. I built my own version of it into my coding-agent setup, ran it on&lt;br&gt;
real tasks, and switched it off four days later.&lt;/p&gt;

&lt;p&gt;This is a post in my &lt;strong&gt;Scars&lt;/strong&gt; series: things I tried or got wrong, and what I&lt;br&gt;
changed after. If you run AI coding agents on long tasks and wonder whether an&lt;br&gt;
explicit state file is worth it, it's for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is SKILL.state?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2608.26263" rel="noopener noreferrer"&gt;SKILL.state&lt;/a&gt; (Badhe, Tiwari and Chung, 2026)&lt;br&gt;
is a runtime design for long agent tasks. Most agents keep everything in one&lt;br&gt;
growing chat: every observation, action and piece of reasoning is appended to&lt;br&gt;
the history. Over a long task that gets slow, and old, wrong details "poison"&lt;br&gt;
later steps.&lt;/p&gt;

&lt;p&gt;SKILL.state replaces that history with an explicit, mutable execution state. At&lt;br&gt;
each step the model gets only three things: the skill it follows, the current&lt;br&gt;
state, and the latest observation. Its reasoning is thrown away once it has&lt;br&gt;
produced a valid state update. The paper reports better accuracy with much lower&lt;br&gt;
token use.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tried it
&lt;/h2&gt;

&lt;p&gt;My agents run in Claude Code, which always keeps the full chat history. I can't&lt;br&gt;
build every prompt myself the way the paper's runtime does, so I approximated&lt;br&gt;
the idea with a skill:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A state file per task.&lt;/strong&gt; The lead agent kept the task in a JSON file inside
the worktree: the objective, the phase (spec, design, issue, implementation,
review, merge), the decisions, the reviewer's findings and the next action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changes only through a small CLI.&lt;/strong&gt; Every update was a patch the CLI
checked against a schema before applying it, so a step could not leave the
state broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fresh subagent per step.&lt;/strong&gt; Each step's work went to a new subagent whose
whole brief was the skill, the current state and the latest observation. No
conversation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The plan was a fair comparison. My task flow alternated: one task with the&lt;br&gt;
skill, the next without. Once each side had enough tasks, I would compare how&lt;br&gt;
often the first review passed, how often I had to correct the agent, and the&lt;br&gt;
minutes per task. The skill would become the default if it was at least as&lt;br&gt;
reliable and not much slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I stopped after four tasks: three with the skill, one without.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;Review rounds&lt;/th&gt;
&lt;th&gt;Minutes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;SKILL.state&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;SKILL.state&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;normal&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;SKILL.state&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I didn't have to correct the agent in any of them, and none of the four&lt;br&gt;
conversations was compacted. Four tasks prove nothing on their own. What made me&lt;br&gt;
stop was what I saw inside them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What helped
&lt;/h3&gt;

&lt;p&gt;The briefs were good. Each subagent got a small, self-contained frame and needed&lt;br&gt;
no history. The reviewer needed no extra context. When a review asked for fixes,&lt;br&gt;
a fresh implementer picked them up from the state alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  What got in the way
&lt;/h3&gt;

&lt;p&gt;Every run with the skill hit several of these, and the lead agent fixed them by&lt;br&gt;
hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Patches that failed the schema.&lt;/strong&gt; Subagents wrote state updates the CLI
rejected, and the lead had to write some of them itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invented timestamps.&lt;/strong&gt; The schema wanted a time on every finding. The
subagents made them up, hours away from the real time, and the lead replaced
them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A state folder that broke the checks.&lt;/strong&gt; &lt;code&gt;deno fmt&lt;/code&gt; and &lt;code&gt;deno lint&lt;/code&gt; scanned
the state folder, so it had to be moved out of the way before every check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In one task a subagent also ignored an instruction not to push, and the lead had&lt;br&gt;
to rewrite a public commit message. That one isn't the skill's fault, but more&lt;br&gt;
handoffs meant more places for an instruction to get lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I stopped
&lt;/h2&gt;

&lt;p&gt;The problem went away on its own. In the middle of the experiment, I moved my&lt;br&gt;
agents to Opus 5.5&lt;br&gt;
(&lt;a href="https://dev.to/blog/opus-5-5-vs-sonnet-5-agent-costs"&gt;the numbers behind that switch&lt;/a&gt;).&lt;br&gt;
After that, the median peak context was 162K tokens, and only 2 of 48 units of&lt;br&gt;
work were compacted at all. The skill fought a problem I barely had anymore, and&lt;br&gt;
it added bookkeeping to every task.&lt;/p&gt;

&lt;p&gt;The comparison was also too slow to finish. At my pace, enough tasks on each&lt;br&gt;
side would have taken weeks.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A fix for a problem you no longer have is just overhead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I kept
&lt;/h2&gt;

&lt;p&gt;The best part of the idea didn't need the state file: a fresh agent for each&lt;br&gt;
step, with a brief that holds everything it needs. That's already how my agents&lt;br&gt;
work. A lead splits the work into lanes, each implementer starts clean with its&lt;br&gt;
own brief, and a separate reviewer checks every diff. Later I removed the skill&lt;br&gt;
from my setup.&lt;/p&gt;

&lt;p&gt;My test was not the paper's setup. I approximated the idea inside a tool that&lt;br&gt;
keeps the whole conversation, on a model with plenty of room. With a smaller&lt;br&gt;
context window, a weaker model, or a runtime that builds every prompt itself,&lt;br&gt;
I'd expect SKILL.state to do better than it did for me.&lt;/p&gt;

&lt;p&gt;My agent rules and skills are public in my &lt;a href="https://dev.to/tools/dotfiles"&gt;dotfiles&lt;/a&gt;, if you&lt;br&gt;
want to see how the lanes and briefs work.&lt;/p&gt;

&lt;p&gt;Have you tried an explicit state file with your agents, and did it pay off?&lt;br&gt;
Email me at &lt;a href="mailto:hi@antonshubin.com"&gt;hi@antonshubin.com&lt;/a&gt;. It comes straight&lt;br&gt;
to me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://antonshubin.com/blog/skill-state-in-practice?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=skill-state-in-practice" rel="noopener noreferrer"&gt;antonshubin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>coding</category>
      <category>agents</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>I tested Trail-code on my Claude Code agents. They ignored the map.</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:56:48 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/i-tested-trail-code-on-my-claude-code-agents-they-ignored-the-map-4poa</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/i-tested-trail-code-on-my-claude-code-agents-they-ignored-the-map-4poa</guid>
      <description>&lt;p&gt;Trail-code promises cheaper, faster coding agents by giving them a map of your code. I replayed 10 merged PRs with and without it, in both of its modes, and reviewed every result blind. This post is what I found and how I tested it, so you can run the same test on the next tool that makes this promise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trail-code's code map changed nothing for my Opus 5.5 agents over 10 tasks: 7 map calls in one mode, none in the other.&lt;/li&gt;
&lt;li&gt;Its prompt hook matched only 2 of 10 issue-sized briefs, so in most runs nothing was pushed to the agent.&lt;/li&gt;
&lt;li&gt;The same task's cost moved by about 13% from run to run. At that noise, 10 tasks can't prove a saving smaller than about 13%.&lt;/li&gt;
&lt;li&gt;Test a tool on your own work: replay merged PRs from the commit before, with and without it, review blind, and measure the noise first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Seed&lt;/strong&gt;: Live: &lt;a href="https://seedsaas.com" rel="noopener noreferrer"&gt;Open the live demo&lt;/a&gt; · Code: &lt;a href="https://github.com/spy4x/seed" rel="noopener noreferrer"&gt;github.com/spy4x/seed&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Trail-code says it makes Claude Code, Cursor and Codex faster and cheaper by&lt;br&gt;
giving them a map of your code. Its README shows 32% lower cost and 60% less&lt;br&gt;
time. It was trending, and my agents' bill is a number I watch every week, so I&lt;br&gt;
tested it on my own work: 10 real tasks, two modes, blind reviews.&lt;/p&gt;

&lt;p&gt;My agents barely touched the map. That is the short version. The longer version&lt;br&gt;
is how I tested it, which you can reuse for the next tool that promises the same&lt;br&gt;
thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Trail-code is
&lt;/h2&gt;

&lt;p&gt;Trail-code (formerly graft) has two parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The code map.&lt;/strong&gt; It parses your repo with tree-sitter into a graph of every
file, function and caller. No model and no network: on my Seed repo, 885
files took about 23 seconds. Agents query it for "where is X" and "who calls
Y" instead of grepping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learnings.&lt;/strong&gt; Agents save what they worked out from your conversations into
files in the repo, so the next session starts from them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I tested only the map. My agents work under written rules, and a decision only&lt;br&gt;
lands in those rules after I approve it. Notes that agents write into the repo&lt;br&gt;
on their own don't fit that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;My agents work in waves: an implementer writes the code for one GitHub issue, a&lt;br&gt;
separate reviewer checks it, and nothing merges until the reviewer passes it. I&lt;br&gt;
wrote about the setup and its costs in&lt;br&gt;
&lt;a href="https://dev.to/blog/opus-5-5-vs-sonnet-5-agent-costs"&gt;Opus 5.5 vs Sonnet 5&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Comparing two different weeks of work tells you nothing, because the tasks&lt;br&gt;
differ. So I replayed work that was already done:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I picked 10 PRs that had already merged in &lt;a href="https://dev.to/blog/seed-saas-starter"&gt;Seed&lt;/a&gt;,
my SaaS starter: auth and billing fixes, a refactor, infrastructure, caching,
structured data, tests.&lt;/li&gt;
&lt;li&gt;For each one I made a fresh clone at the commit &lt;strong&gt;before&lt;/strong&gt; the PR, one commit
deep, so the answer was nowhere in its history.&lt;/li&gt;
&lt;li&gt;An Opus 5.5 implementer got the original issue as its brief and built it with
the map and without it. The runs of a task went in parallel, so they saw the
same machine load.&lt;/li&gt;
&lt;li&gt;A reviewer checked every result &lt;strong&gt;blind&lt;/strong&gt;, on a clean copy under a random
letter, with the merged PR as a reference answer.&lt;/li&gt;
&lt;li&gt;Afterwards I scanned every tool call to check that no agent peeked at the
real repo or the real PR.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Trail-code gives an agent the map in two ways, so I tested both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: the map as tools ("pull")
&lt;/h2&gt;

&lt;p&gt;The agent gets MCP tools like &lt;code&gt;trail_find_code&lt;/code&gt; and &lt;code&gt;trail_trace_calls&lt;/code&gt;, and&lt;br&gt;
calls them when it wants to. I added one line to the brief: use the map before&lt;br&gt;
grepping.&lt;/p&gt;

&lt;p&gt;Over 10 tasks the agents made &lt;strong&gt;7 map calls&lt;/strong&gt;. Everything else was &lt;code&gt;grep&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;sed&lt;/code&gt; through Bash, exactly as without the map. On the 9 comparable tasks, the&lt;br&gt;
runs with the map cost 3.9% less in total, and the per-task change swung from&lt;br&gt;
18% cheaper to 26% dearer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost per task, round 1, the map as tools&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Without the map&lt;/th&gt;
&lt;th&gt;With the map&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Map calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IPv6 sign-up budget per /48&lt;/td&gt;
&lt;td&gt;$0.68&lt;/td&gt;
&lt;td&gt;$1.24&lt;/td&gt;
&lt;td&gt;not compared: different judgment; the run without the map stopped, thinking a library release was needed&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One day constant&lt;/td&gt;
&lt;td&gt;$1.38&lt;/td&gt;
&lt;td&gt;$1.36&lt;/td&gt;
&lt;td&gt;−1.4%&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection pooling&lt;/td&gt;
&lt;td&gt;$3.90&lt;/td&gt;
&lt;td&gt;$3.67&lt;/td&gt;
&lt;td&gt;−5.9%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests for uncovered behaviour&lt;/td&gt;
&lt;td&gt;$3.77&lt;/td&gt;
&lt;td&gt;$3.41&lt;/td&gt;
&lt;td&gt;−9.5%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Init task and Traefik in dev&lt;/td&gt;
&lt;td&gt;$2.33&lt;/td&gt;
&lt;td&gt;$2.02&lt;/td&gt;
&lt;td&gt;−13.3%&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Author line in page hero&lt;/td&gt;
&lt;td&gt;$2.29&lt;/td&gt;
&lt;td&gt;$2.26&lt;/td&gt;
&lt;td&gt;−1.3%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache static assets&lt;/td&gt;
&lt;td&gt;$1.92&lt;/td&gt;
&lt;td&gt;$2.42&lt;/td&gt;
&lt;td&gt;+26%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One product in structured data&lt;/td&gt;
&lt;td&gt;$1.87&lt;/td&gt;
&lt;td&gt;$1.53&lt;/td&gt;
&lt;td&gt;−18.2%&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear browser data on sign-out&lt;/td&gt;
&lt;td&gt;$2.55&lt;/td&gt;
&lt;td&gt;$2.27&lt;/td&gt;
&lt;td&gt;−11%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancel dialog after the grace period&lt;/td&gt;
&lt;td&gt;$2.28&lt;/td&gt;
&lt;td&gt;$2.48&lt;/td&gt;
&lt;td&gt;+8.8%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All 9 comparable tasks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$22.29&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$21.42&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−3.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3.9% cheaper in total on 9 comparable tasks, inside the −13% to +13% noise, so it proves nothing. 7 map calls in 10 tasks.&lt;/p&gt;

&lt;p&gt;Try it on antonshubin.com: &lt;a href="https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test#cost-pull" rel="noopener noreferrer"&gt;https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test#cost-pull&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One task is left out. On the auth fix, the run without the map decided the fix&lt;br&gt;
needed a new release of my shared library first, and stopped. The run with the&lt;br&gt;
map searched the library, found the option was already released, and finished&lt;br&gt;
the job. Its one map call had nothing to do with that. It was a different&lt;br&gt;
judgment call on the same evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: the map pushed into the prompt ("push")
&lt;/h2&gt;

&lt;p&gt;Trail-code's own benchmark found the bigger saving in "push" mode, where the&lt;br&gt;
matching code reaches the agent without being asked for. In Claude Code, its&lt;br&gt;
setup does this with hooks that run at session start, on every prompt and after&lt;br&gt;
every edit, plus a skill that tells the agent to use &lt;code&gt;trail&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I used exactly those hooks, taken from a real &lt;code&gt;trail init&lt;/code&gt;, and gave them to&lt;br&gt;
each run through its own settings file, so my global config stayed untouched.&lt;br&gt;
This time the brief said nothing about the map: the hooks are the product.&lt;/p&gt;

&lt;p&gt;The agents made zero map calls. Then I looked at what the hooks actually sent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At session start: about 3 KB of instructions to use &lt;code&gt;trail&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On the brief: "no strong match for this prompt" on 8 of the 10 briefs.&lt;/li&gt;
&lt;li&gt;After edits and searches: nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I fed the prompt hook by hand. The bare issue text of the refactor, which&lt;br&gt;
names the exact file and the exact constant, got no match. One-line questions&lt;br&gt;
did better, but not well: 2 of 7 returned the right files. "Where is the IPv6&lt;br&gt;
sign-up rate limit?" worked. "Which file defines JOB_AGGREGATE_ID?" returned&lt;br&gt;
nothing.&lt;/p&gt;

&lt;p&gt;The prompt matching is built for a short chat message. My agents get issue-sized&lt;br&gt;
briefs, so push mode mostly pushed nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost per task, round 2, the map pushed into the prompt&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Without the map&lt;/th&gt;
&lt;th&gt;With the map&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Map calls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IPv6 sign-up budget per /48&lt;/td&gt;
&lt;td&gt;$1.18&lt;/td&gt;
&lt;td&gt;$1.09&lt;/td&gt;
&lt;td&gt;−7.6%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One day constant&lt;/td&gt;
&lt;td&gt;$1.22&lt;/td&gt;
&lt;td&gt;$1.28&lt;/td&gt;
&lt;td&gt;+4.9%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection pooling&lt;/td&gt;
&lt;td&gt;$3.63&lt;/td&gt;
&lt;td&gt;$2.64&lt;/td&gt;
&lt;td&gt;−27.3%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests for uncovered behaviour&lt;/td&gt;
&lt;td&gt;$3.42&lt;/td&gt;
&lt;td&gt;$3.18&lt;/td&gt;
&lt;td&gt;−7%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Init task and Traefik in dev&lt;/td&gt;
&lt;td&gt;$2.33&lt;/td&gt;
&lt;td&gt;$2.58&lt;/td&gt;
&lt;td&gt;+10.7%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Author line in page hero&lt;/td&gt;
&lt;td&gt;$2.29&lt;/td&gt;
&lt;td&gt;$1.70&lt;/td&gt;
&lt;td&gt;−25.8%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache static assets&lt;/td&gt;
&lt;td&gt;$1.92&lt;/td&gt;
&lt;td&gt;$2.04&lt;/td&gt;
&lt;td&gt;+6.3%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One product in structured data&lt;/td&gt;
&lt;td&gt;$1.87&lt;/td&gt;
&lt;td&gt;$1.78&lt;/td&gt;
&lt;td&gt;−4.8%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear browser data on sign-out&lt;/td&gt;
&lt;td&gt;$2.55&lt;/td&gt;
&lt;td&gt;$2.08&lt;/td&gt;
&lt;td&gt;−18.4%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancel dialog after the grace period&lt;/td&gt;
&lt;td&gt;$2.28&lt;/td&gt;
&lt;td&gt;$1.93&lt;/td&gt;
&lt;td&gt;−15.4%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All 10 comparable tasks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$22.69&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$20.30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−10.5%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;10.5% cheaper in total on 10 comparable tasks, inside the −13% to +13% noise, so it proves nothing. 0 map calls in 10 tasks.&lt;/p&gt;

&lt;p&gt;Try it on antonshubin.com: &lt;a href="https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test#cost-push" rel="noopener noreferrer"&gt;https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test#cost-push&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The push runs cost 10.5% less in total and took 8% less time. They also got the&lt;br&gt;
worse reviews: 6 of 10 passed, against 8 of 10 without the map. And the saving&lt;br&gt;
doesn't line up with the map: on the two briefs where the hook did send code,&lt;br&gt;
one run got 11% dearer and the other 15% cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that decides it: noise
&lt;/h2&gt;

&lt;p&gt;A 10% saving when nothing reached the agent in 8 tasks out of 10? That can't be&lt;br&gt;
the tool.&lt;/p&gt;

&lt;p&gt;On the first four tasks I ran the baseline twice. On the three where both runs&lt;br&gt;
did the job, two identical runs, same model, same brief, differed by 7–12% in&lt;br&gt;
cost. Across the compared tasks, &lt;strong&gt;the cost change with the map has a spread of&lt;br&gt;
about 13% from task to task,&lt;/strong&gt; in both modes. That spread is the noise any&lt;br&gt;
saving has to beat.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If two identical runs differ by 10%, a tool that saves 10% looks exactly like&lt;br&gt;
no tool at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the main thing I took away. Before you believe a saving, from a vendor or&lt;br&gt;
from your own test, measure the noise. At 13% noise, 10 tasks can only show a&lt;br&gt;
saving of about 13% or more. Neither mode got there, and the push mode's 10%&lt;br&gt;
comes with worse reviews. Trail-code's 32% would have shown up clearly, and it&lt;br&gt;
didn't.&lt;/p&gt;

&lt;p&gt;How many tasks would you need? Set your own noise, then the saving a vendor&lt;br&gt;
promised you.&lt;/p&gt;

&lt;p&gt;Tasks needed, at 95% confidence and 80% power:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runs differ by&lt;/th&gt;
&lt;th&gt;5% saving&lt;/th&gt;
&lt;th&gt;10% saving&lt;/th&gt;
&lt;th&gt;20% saving&lt;/th&gt;
&lt;th&gt;40% saving&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;n = ⌈(2.8 × noise ÷ saving)² + 2⌉, never below 3.&lt;/p&gt;

&lt;p&gt;Try it on antonshubin.com: &lt;a href="https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test#how-many-tasks" rel="noopener noreferrer"&gt;https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test#how-many-tasks&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why their benchmark and my test disagree
&lt;/h2&gt;

&lt;p&gt;I don't think Trail-code's numbers are wrong. They measure something else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Their headline numbers come from questions about code&lt;/strong&gt;, answered by
Sonnet 5. Mine were full implementations: read the issue, change the code,
write tests, run checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their agents started cold.&lt;/strong&gt; Mine read a repo with a detailed AGENTS.md, and
Opus 5.5 navigates with grep quickly and cheaply. A map saves little when
finding the file was never the expensive part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their prompts are short.&lt;/strong&gt; Push mode matches on the prompt, and a lane brief
isn't a chat message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Cursor on a large repo you don't know, with short questions, your&lt;br&gt;
result may well be different. That's the point: the only benchmark that answers&lt;br&gt;
your question is your work.&lt;/p&gt;

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

&lt;p&gt;Everything together, 34 implementer runs and 33 blind reviews: about $101 at API&lt;br&gt;
list prices. That's cheap compared with a month of a tool that adds hooks to&lt;br&gt;
every session and does nothing for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try this on the next tool
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick merged PRs of different kinds. Ten is a good start.&lt;/li&gt;
&lt;li&gt;Clone each at the commit before it, one commit deep.&lt;/li&gt;
&lt;li&gt;Run the same agent with and without the tool, in parallel.&lt;/li&gt;
&lt;li&gt;Review blind, with the merged PR as the reference answer.&lt;/li&gt;
&lt;li&gt;Run the baseline twice, so you know your noise before you read any saving,
and check with the calculator above whether your task count can show it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deciding whether an AI tool is worth it for your team? In a&lt;br&gt;
&lt;a href="https://dev.to/catalog/strategy-call"&gt;60-minute strategy session&lt;/a&gt; we can plan this test for&lt;br&gt;
your own agents and repo.&lt;/p&gt;

&lt;p&gt;Which tool should I put through this next? Reply to the newsletter or write to&lt;br&gt;
me. And if you want the next test in your inbox, subscribe below.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://antonshubin.com/blog/trail-code-claude-code-test?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trail-code-claude-code-test" rel="noopener noreferrer"&gt;antonshubin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>testing</category>
      <category>tools</category>
    </item>
    <item>
      <title>preact-components: a Preact component library without shadcn or Radix</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Wed, 30 Sep 2026 16:42:52 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/preact-components-a-preact-component-library-without-shadcn-or-radix-1n8p</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/preact-components-a-preact-component-library-without-shadcn-or-radix-1n8p</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;preact-components is my open-source Preact component library: ten JSR packages, rendered on the server, with roles, keyboard handling and focus written by hand.&lt;/li&gt;
&lt;li&gt;I own my components instead of using shadcn, Radix or MUI, because working around their styling, upgrades and limits cost me more than owning them.&lt;/li&gt;
&lt;li&gt;Owning components means owning accessibility, so headless Chromium checks the keyboard handling on every pull request.&lt;/li&gt;
&lt;li&gt;It is beta: no screen reader has been run against it, only Deno is tested, and it is Preact and Tailwind only. Try any component in the live guide first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;preact-components&lt;/strong&gt;: Live: &lt;a href="https://spy4x.github.io/preact-components" rel="noopener noreferrer"&gt;Open the live UI guide&lt;/a&gt; · Code: &lt;a href="https://github.com/spy4x/preact-components" rel="noopener noreferrer"&gt;github.com/spy4x/preact-components&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fdashboard-hero.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fdashboard-hero.webp" title="Every part of this demo dashboard is a component from the packages." alt="A dashboard in the live guide built only from the library: a frame titled Orbit with Run checks and New project buttons, a filter column, four KPI tiles, a line chart of passed and failed builds this week, and a sortable table of five projects with status badges" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every new app I start needs the same things: a button, a table, a dialog, a form&lt;br&gt;
field, a chart, a signed-in layout. I kept rebuilding them, or pulling in a&lt;br&gt;
component library and then working around it.&lt;/p&gt;

&lt;p&gt;preact-components is where that stopped. It is an open-source Preact component&lt;br&gt;
library: ten packages on JSR, every component rendered on the server and&lt;br&gt;
hydrated in the browser, with roles, labels, keyboard handling and focus written&lt;br&gt;
by hand. No shadcn, Radix, Headless UI or Material underneath.&lt;/p&gt;

&lt;p&gt;This post is for developers who build with Preact and Tailwind and wonder&lt;br&gt;
whether owning the UI layer is worth it. I'll show why I decided it is, what is&lt;br&gt;
in the ten packages, how to try it in a minute, and what is not done yet.&lt;/p&gt;

&lt;p&gt;The dashboard above is on the guide's first page. Every part of it is a&lt;br&gt;
component from these packages, running on local state: you can filter and sort&lt;br&gt;
the projects, run the checks for a toast, and add a project in the dialog.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why not shadcn, Radix or MUI?
&lt;/h2&gt;

&lt;p&gt;Two of my earlier apps used shadcn, Radix and bits-ui. Over time those libraries&lt;br&gt;
cost me more to work around than it would have cost to own the components, in&lt;br&gt;
three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Styling opinions.&lt;/strong&gt; A library ships a look. I already had design tokens.
Either I fight its defaults on every component, or I slowly give up my
tokens. The second one wins, quietly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade breakage.&lt;/strong&gt; A component library changes its API and its rendered
markup. Every upgrade reopens components that already worked, for no product
reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension limits.&lt;/strong&gt; The component I need is never quite the one it ships.
The workaround is often more code than the component, and it inherits the
library's constraints on top.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the repository has a written rule: every component is written there. No&lt;br&gt;
third-party component library is a dependency, and a copy of one pasted into the&lt;br&gt;
tree counts as one too. The old apps are still useful, but only as a reference&lt;br&gt;
for design intent: I read how they compose a dialog and write my own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Port the markup and the behaviour. Never the dependency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That doesn't mean I write everything. My rule is "own the small, keep the huge".&lt;br&gt;
Preact, signals, Tailwind, arktype, wouter and Leaflet stay: they are large,&lt;br&gt;
well-solved problems, and none of them is a component library. A button, a&lt;br&gt;
dialog or a combobox is small enough to own, and opinionated enough that I'd&lt;br&gt;
fight someone else's defaults.&lt;/p&gt;
&lt;h2&gt;
  
  
  The real price: accessibility
&lt;/h2&gt;

&lt;p&gt;Owning components means owning everything a library used to hide. The repository&lt;br&gt;
names it plainly, so nobody pretends it is free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;focus traps: moving focus into a dialog, keeping it there, returning it on
close;&lt;/li&gt;
&lt;li&gt;dismissal: an outside click, &lt;code&gt;Escape&lt;/code&gt;, and the difference between dismissing
and closing on purpose;&lt;/li&gt;
&lt;li&gt;ARIA wiring: which &lt;code&gt;role&lt;/code&gt;, &lt;code&gt;aria-expanded&lt;/code&gt; or &lt;code&gt;aria-labelledby&lt;/code&gt; goes on which
element;&lt;/li&gt;
&lt;li&gt;keyboard navigation: arrow keys, &lt;code&gt;Home&lt;/code&gt; and &lt;code&gt;End&lt;/code&gt;, typeahead;&lt;/li&gt;
&lt;li&gt;positioning: anchoring a popup to its trigger and flipping it when it would
overflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fdialog-focus.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fdialog-focus.webp" title="The demo's New project dialog, a native dialog element: focus moves into its field when it opens." alt="The New project dialog open over the guide's demo dashboard: a Project name field with a focus ring, the hint " width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Where the platform already has an answer, I use it: native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; and forms that post before any script has run. A native&lt;br&gt;
element ships its semantics for free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fcombobox-keyboard.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fcombobox-keyboard.webp" title="The Combobox, driven from the keyboard in the live guide." alt="The Combobox card in the live guide: the city field has focus and its list is open, and the arrow keys have highlighted São Paulo" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Combobox above is a good example. Every one renders a single &lt;code&gt;role="status"&lt;/code&gt;&lt;br&gt;
live region, already in the server HTML and empty until the field is used, so&lt;br&gt;
the answers it announces have a stable place to go.&lt;/p&gt;

&lt;p&gt;How do I know the keyboard handling works? The packages that need a hydrated&lt;br&gt;
page have browser checks in &lt;code&gt;pages/checks/&lt;/code&gt;. On every pull request, headless&lt;br&gt;
Chromium presses the keys, moves focus, opens dialogs and toasts, and asserts&lt;br&gt;
what happens, instead of trusting the markup. The repository also has 139 test&lt;br&gt;
files (counted with &lt;code&gt;find . -name "*.test.ts*"&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;
  
  
  Rendered on the server, then hydrated
&lt;/h2&gt;

&lt;p&gt;A component touches &lt;code&gt;window&lt;/code&gt; or &lt;code&gt;document&lt;/code&gt; only inside an effect or an event&lt;br&gt;
handler. So every one renders complete HTML on the server and hydrates in the&lt;br&gt;
browser. The live guide itself is served that way, and it reads with JavaScript&lt;br&gt;
off.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. A page that shows its content before the&lt;br&gt;
JavaScript arrives is faster for the visitor and easier for a search engine. And&lt;br&gt;
a component that only works after hydration usually hides a bug that shows up on&lt;br&gt;
a slow phone.&lt;/p&gt;

&lt;p&gt;Colours, radii and fonts are CSS custom properties, so an app restyles&lt;br&gt;
everything by setting variables instead of forking the CSS. Layout goes through&lt;br&gt;
&lt;code&gt;Page&lt;/code&gt;, &lt;code&gt;Section&lt;/code&gt;, &lt;code&gt;Stack&lt;/code&gt;, &lt;code&gt;Cluster&lt;/code&gt; and &lt;code&gt;Grid&lt;/code&gt;, and no component carries an&lt;br&gt;
outer margin. The guide's header switches between light and dark and changes the&lt;br&gt;
accent colour live, so you can see the tokens at work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fkpi-dark-light.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fkpi-dark-light.webp" title="The same Kpi tiles in the guide's dark theme and its light theme." alt="The same five Kpi tiles from the live guide twice, each showing 42 in the accent, positive, warning, negative and neutral tones: on a dark background above and on a light one below" width="800" height="275"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What is in the ten packages
&lt;/h2&gt;

&lt;p&gt;Every package is published on JSR under &lt;code&gt;@spy4x/preact-*&lt;/code&gt;, all at the same&lt;br&gt;
version, so a caret range always resolves to a set that was published together.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the component set: layout, buttons, tables, dialogs, forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-theme&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;design tokens, the Tailwind preset and the classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-icons&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the merged icon set, one component per glyph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-charts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;server-rendered charts with tooltips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-system&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;app-level pieces: auth form, calendar, shells, SEO head&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-crud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a list and editor scaffold for one collection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-map&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a map on Leaflet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-signals&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stores and state helpers; no components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-cn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;class-name join with Tailwind conflict resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@spy4x/preact-ui-guide&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the live component catalogue, mounted in one line&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The guide's overview counts 81 components and 120 icons across them today.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fline-chart-tooltip.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fantonshubin.com%2Fimg%2Fblog%2Fpreact-component-library-without-shadcn%2Fline-chart-tooltip.webp" title="A LineChart from @spy4x/preact-charts, with its tooltip open." alt="The LineChart card in the live guide: revenue today and a week earlier by hour, a dashed target line at 12, and a tooltip at 14:00 that shows no value for today and 10.9 for a week earlier" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Where it came from
&lt;/h2&gt;

&lt;p&gt;The components were extracted from real products, so I stop rebuilding the same&lt;br&gt;
pieces for the next one. My &lt;a href="https://dev.to/blog/deno-platform-template"&gt;SaaS template&lt;/a&gt;&lt;br&gt;
already builds its signed-in layout on &lt;code&gt;Shell&lt;/code&gt; from &lt;code&gt;@spy4x/preact-system&lt;/code&gt; and&lt;br&gt;
its styles on &lt;code&gt;@spy4x/preact-theme&lt;/code&gt;. This site is next: it still has its own&lt;br&gt;
components, and replacing them with the library is an open issue.&lt;/p&gt;

&lt;p&gt;The design is not mine. The design system, the component styling and the&lt;br&gt;
original markup are by &lt;a href="https://github.com/Eirene" rel="noopener noreferrer"&gt;Eirene&lt;/a&gt;&lt;br&gt;
(&lt;a href="https://isorokina.com/" rel="noopener noreferrer"&gt;isorokina.com&lt;/a&gt;). She designed it and built the&lt;br&gt;
original pages the components were first extracted from. What I added is the&lt;br&gt;
JavaScript: the Preact and signals implementation, the interactivity, server&lt;br&gt;
rendering, tests, packaging and documentation.&lt;/p&gt;

&lt;p&gt;Helpers with no UI, like dates and validation, come from my other library,&lt;br&gt;
&lt;a href="https://dev.to/tools/ts-libs"&gt;ts-libs&lt;/a&gt;, under the same &lt;code&gt;@spy4x&lt;/code&gt; scope on JSR.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it in a minute
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://spy4x.github.io/preact-components" rel="noopener noreferrer"&gt;live guide&lt;/a&gt;. Every&lt;br&gt;
component runs there with its code one click away, so you can try one before you&lt;br&gt;
install anything.&lt;/p&gt;

&lt;p&gt;When one looks right, add the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;deno add jsr:@spy4x/preact-ui
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And render it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Badge&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="s2"&gt;@spy4x/preact-ui/badge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Badge&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Active"&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"green"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest catch: the components render Tailwind classes against the compiled&lt;br&gt;
stylesheet from &lt;code&gt;@spy4x/preact-theme&lt;/code&gt;, and JSR cannot export a CSS file. So the&lt;br&gt;
stylesheet takes a short build script instead of a plain &lt;code&gt;@import&lt;/code&gt;. The theme&lt;br&gt;
package's README has the recipe, one short TypeScript file.&lt;/p&gt;

&lt;p&gt;The repository also has an &lt;code&gt;llms.txt&lt;/code&gt;, so a coding agent can read the whole&lt;br&gt;
component surface in one file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not done yet
&lt;/h2&gt;

&lt;p&gt;I mark it beta on my site, and here is why. The version is 1.2.0 and it follows&lt;br&gt;
semantic versioning since 1.0.0, so a breaking change bumps the major number.&lt;br&gt;
But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No screen reader has been run against it yet.&lt;/strong&gt; The browser checks prove the
markup and the order in which the page changes, not what a screen reader
actually speaks. That work is an open issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only Deno is tested.&lt;/strong&gt; The packages are standard ES modules with no
Deno-only API, and a Vite app can use them through &lt;code&gt;@deno/vite-plugin&lt;/code&gt;.
Running them under Node or Bun has not been tried.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is Preact and Tailwind only.&lt;/strong&gt; If your app is React, or you don't use
Tailwind, skip it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some components are deliberately not built.&lt;/strong&gt; Carousel, Accordion, Drawer, a
rich-text editor and a tree view were considered and turned down: nothing I
run needs them, and some would need a large third-party piece I don't want.
The reasons are written down in the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing in CI enforces the no-library rule.&lt;/strong&gt; A pull request could add Radix
and every check would pass. Review holds the rule, and the repository says so
instead of pretending a check exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What next?
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://spy4x.github.io/preact-components" rel="noopener noreferrer"&gt;live guide&lt;/a&gt;, press Tab&lt;br&gt;
through a dialog, and see if it behaves the way you expect. If something is&lt;br&gt;
missing or broken, open an issue on&lt;br&gt;
&lt;a href="https://github.com/spy4x/preact-components" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. And if the library looks&lt;br&gt;
useful, a star helps other Preact developers find it.&lt;/p&gt;

&lt;p&gt;Building a product and want its UI layer owned like this, instead of rented from&lt;br&gt;
a library? That's my day job: see&lt;br&gt;
&lt;a href="https://dev.to/catalog/zero-to-production-saas-mvp"&gt;how I build a SaaS MVP&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://antonshubin.com/blog/preact-component-library-without-shadcn?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=preact-component-library-without-shadcn" rel="noopener noreferrer"&gt;antonshubin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Opus 5.5 vs Sonnet 5: the pricier model wrote my code for about half the cost</title>
      <dc:creator>Anton Shubin</dc:creator>
      <pubDate>Sat, 26 Sep 2026 05:54:16 +0000</pubDate>
      <link>https://dev.to/anton_shubin_f26b3cb5a572/opus-55-vs-sonnet-5-the-pricier-model-wrote-my-code-for-about-half-the-cost-2nn5</link>
      <guid>https://dev.to/anton_shubin_f26b3cb5a572/opus-55-vs-sonnet-5-the-pricier-model-wrote-my-code-for-about-half-the-cost-2nn5</guid>
      <description>&lt;p&gt;Opus 5.5 lists at twice the price of Sonnet 5. In my coding agents it cost about&lt;br&gt;
half as much per line of code. It also replaced Fable 5.1 as my code reviewer,&lt;br&gt;
at about a third of the price.&lt;/p&gt;

&lt;p&gt;This post is the data behind that switch: five days of my own Claude Code&lt;br&gt;
transcripts, priced at API list prices, with the parts that are proven kept&lt;br&gt;
apart from the parts that are only likely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I run agents
&lt;/h2&gt;

&lt;p&gt;I work in waves. A lead model reads a list of GitHub issues and splits them into&lt;br&gt;
lanes that touch different files. Each lane gets an &lt;strong&gt;implementer&lt;/strong&gt; agent that&lt;br&gt;
writes the code in its own git worktree and opens a PR. A separate &lt;strong&gt;reviewer&lt;/strong&gt;&lt;br&gt;
agent then checks every PR: it runs the tests, breaks the code on purpose to see&lt;br&gt;
whether a test goes red, and checks the PR description's claims. Nothing merges&lt;br&gt;
until a reviewer passes it. A failed review ("needs-fix") goes back to the&lt;br&gt;
implementer for another round.&lt;/p&gt;

&lt;p&gt;Between September 20 and 25 that produced 203 implementer PRs and 411 reviewer&lt;br&gt;
agents across four models. I pay a flat subscription, so the dollars below are&lt;br&gt;
API list prices, used as a measure of how much of my usage limit each agent&lt;br&gt;
burned.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input $/M&lt;/th&gt;
&lt;th&gt;Output $/M&lt;/th&gt;
&lt;th&gt;Cached read $/M&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5.5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fable 5.1&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last column is the one that matters, and the next section shows why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the money actually goes
&lt;/h2&gt;

&lt;p&gt;An agent doesn't pay for its work once. Every step is a new API call that sends&lt;br&gt;
the whole conversation again: the brief, every file it read, every test log.&lt;br&gt;
Most of that comes from the prompt cache, so each call is cheap, but an agent&lt;br&gt;
makes hundreds of them.&lt;/p&gt;

&lt;p&gt;In one six-hour window on September 24, my agents used about $593 at list&lt;br&gt;
prices. &lt;strong&gt;68% of that was cached reads&lt;/strong&gt;, 22% cache writes and 10% output.&lt;br&gt;
Across 57 agents, cost tracked "number of calls × average context size" almost&lt;br&gt;
perfectly (correlation 0.99). The worst offenders were Sonnet implementers that&lt;br&gt;
ran 400–600 calls while their context grew to 600–970K tokens, because nothing&lt;br&gt;
compacted it before about 967K. Every one of those calls re-read the whole&lt;br&gt;
thing.&lt;/p&gt;

&lt;p&gt;So the price per token matters less than you'd think. What matters is how many&lt;br&gt;
steps an agent takes and how much it carries through each one.&lt;/p&gt;

&lt;p&gt;Cached reads cost the same $0.20 per million tokens on Opus 5.5 and Sonnet 5.&lt;br&gt;
Only input, output and cache writes cost twice as much on Opus. For my Sonnet&lt;br&gt;
implementers, cached reads were about three quarters of the bill (the median&lt;br&gt;
lane), so the same call costs only about 25% more on Opus 5.5, not twice as&lt;br&gt;
much.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb56cgw3b71rx83ghwb63.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb56cgw3b71rx83ghwb63.png" alt="The same agent call priced on each model. Cached reads, three quarters of Sonnet's cost, are the same price on both, so Opus 5.5 costs 1.25 times as much per call, not 2 times" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementers: fewer steps beat a cheaper token
&lt;/h2&gt;

&lt;p&gt;Opus 5 only ran on September 20–21 and Opus 5.5 only on September 23–25, so each&lt;br&gt;
is compared with Sonnet 5 on the same days:&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf3bzp9i1q7w88ylt35z.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf3bzp9i1q7w88ylt35z.png" alt="Cost per 100 changed lines. September 20 to 21: Sonnet 5 $1.57, Opus 5 $2.45. September 23 to 25: Sonnet 5 $2.13, Opus 5.5 $0.39" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Days&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Cost per 100 changed lines&lt;/th&gt;
&lt;th&gt;API calls per 100 lines&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 20–21&lt;/td&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;$1.57&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 20–21&lt;/td&gt;
&lt;td&gt;Opus 5&lt;/td&gt;
&lt;td&gt;$2.45&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 23–25&lt;/td&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;$2.13&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 23–25&lt;/td&gt;
&lt;td&gt;Opus 5.5&lt;/td&gt;
&lt;td&gt;$0.39&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Changed lines" is additions plus deletions in the PR. The median PR was the&lt;br&gt;
same size for Sonnet 5 and Opus 5.5 (484 and 504 lines), so Opus isn't winning&lt;br&gt;
by writing more lines. Over all five days, per 100 changed lines, Opus 5.5 made&lt;br&gt;
6 times fewer calls than Sonnet, read 10 times less context and wrote 3 times&lt;br&gt;
less output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Opus 5 was not wasteful; its price was.&lt;/strong&gt; Its prices are exactly 2.5 times&lt;br&gt;
Sonnet's on every kind of token, so at Sonnet's prices it would have cost $0.98&lt;br&gt;
per 100 lines against Sonnet's $1.57. Opus 5.5 keeps that efficiency and drops&lt;br&gt;
the price.&lt;/p&gt;

&lt;h3&gt;
  
  
  The raw gap overstates it
&lt;/h3&gt;

&lt;p&gt;The table says Opus 5.5 cost a fifth of Sonnet. That isn't a fair comparison,&lt;br&gt;
because I chose which tasks went to Opus, and I gave it mostly authentication&lt;br&gt;
and crypto work. So I compared units of work of the same size and kind, in the&lt;br&gt;
same repository and the same session. Measured that way, &lt;strong&gt;Opus 5.5 cost about&lt;br&gt;
0.45 times what Sonnet did&lt;/strong&gt; (plausible range 0.29–0.71).&lt;/p&gt;

&lt;p&gt;That's still a big saving, and the more interesting part is where it comes from.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs52u0kaj6u3s2c3h94yc.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs52u0kaj6u3s2c3h94yc.png" alt="Opus 5.5 cost relative to Sonnet 5, like for like. Up to the first review: 0.77 times, range 0.52 to 1.13, which includes equal cost. Whole PR including fix rounds: 0.45 times, range 0.29 to 0.71" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first draft costs about the same.&lt;/strong&gt; Up to the first review verdict, Opus&lt;br&gt;
5.5 cost about 0.77 times Sonnet, and that range includes equal cost. Opus did&lt;br&gt;
bundle its work into fewer, longer shell commands: a median of 31 calls before&lt;br&gt;
the first review, against Sonnet's 97.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The saving is in the fix rounds.&lt;/strong&gt; In the September 24 window, fix rounds were&lt;br&gt;
about 71% of implementer spend. A Sonnet implementer that had already grown a&lt;br&gt;
900K context paid $5.60–6.40 for a small last-round fix, because it re-read&lt;br&gt;
everything to make it. Opus carried a smaller context (a median peak of 170K&lt;br&gt;
against Sonnet's 246K), and in the 5 Opus lanes of the fair split I describe at&lt;br&gt;
the end it needed a median of 2 review rounds against Sonnet's 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  Most PRs fail their first review
&lt;/h3&gt;

&lt;p&gt;On ordinary code, most PRs from every model failed their first review. Sonnet 5&lt;br&gt;
and Opus 5 passed 12–20% of the time. In one six-hour window on September 24,&lt;br&gt;
all 20 PRs that changed production code failed their first review.&lt;/p&gt;

&lt;p&gt;Opus 5.5's raw first-review pass rate looks great: 10 of 18. But almost all of&lt;br&gt;
those passes were auth work that I had routed to Opus and that Fable reviewed.&lt;br&gt;
On other code, Opus 5.5 passed 1 of 7, which is too few to say anything. Look at&lt;br&gt;
why PRs fail, and the model matters less: at least 7 of those 20 failures were&lt;br&gt;
tests that stayed green on broken code, or a PR description that claimed&lt;br&gt;
something untrue. I don't expect a smarter model to fix that, so I changed the&lt;br&gt;
process instead; it's too early to know whether that worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  More reasoning effort cost more and bought nothing
&lt;/h2&gt;

&lt;p&gt;Until this week, my implementers ran at whatever effort their lead used, so the&lt;br&gt;
past lanes cover several levels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implementer&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;th&gt;Cost per 100 changed lines&lt;/th&gt;
&lt;th&gt;Passed first review&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;$1.72&lt;/td&gt;
&lt;td&gt;8 of 64 (12%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;td&gt;$2.52&lt;/td&gt;
&lt;td&gt;9 of 53 (17%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5&lt;/td&gt;
&lt;td&gt;xhigh&lt;/td&gt;
&lt;td&gt;$2.56&lt;/td&gt;
&lt;td&gt;1 of 13 (8%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 5.5&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;$0.39&lt;/td&gt;
&lt;td&gt;9 of 16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sonnet at high effort cost 35–51% more per line than at medium, with no clear&lt;br&gt;
gain in review results. Opus 5.5 did its auth and crypto work at medium. This is&lt;br&gt;
observational (repositories ran at different efforts), so it's a signal, not&lt;br&gt;
proof. It was enough to stop me guessing effort up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reviewers: Opus 5.5 against Fable 5.1
&lt;/h2&gt;

&lt;p&gt;On the same kind of PR in the same days, with both priced at Opus 5.5's rates, a&lt;br&gt;
review round cost $1.36 on Opus 5.5 and $1.50 on Fable 5.1: about the same&lt;br&gt;
amount of work. At real list prices, Fable cost about 2.7 times more, and it was&lt;br&gt;
slower (a median of 9 minutes a round against 6.5).&lt;/p&gt;

&lt;p&gt;Fable wasn't stricter either. On Sonnet-written code in the same days, it&lt;br&gt;
blocked 17 of 19 first submissions, and Opus 5.5 blocked 74 of 92. That is no&lt;br&gt;
real difference.&lt;/p&gt;

&lt;p&gt;On the four PRs that both reviewed at the same time, &lt;strong&gt;each caught security bugs&lt;br&gt;
the other missed&lt;/strong&gt;. One was a delete path that followed symbolic links: Fable&lt;br&gt;
caught it, and the Opus review running in parallel did not. So neither reviewer&lt;br&gt;
is complete on its own. Instead of paying 2.7 times more for a second opinion, I&lt;br&gt;
added that class of bug to the reviewer's checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opus 5.5 implements and reviews every diff.&lt;/strong&gt; Fable stays paused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium effort by default.&lt;/strong&gt; Reviews of production code run at xhigh, and
reviews of auth, crypto, secrets, money, deploys and data migrations run at
max. An implementer only moves up to xhigh for one fix round, when a PR's
second failed review names a real behaviour defect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compact at 400K tokens instead of about 967K.&lt;/strong&gt; The single largest saving in
the analysis: it caps how much every call has to re-read. Reviewers, which
peaked at 277K in the window I measured, never hit it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 1-hour cache for reviewers.&lt;/strong&gt; Re-reviews usually arrive after the default
5-minute cache has expired, so the reviewer paid to rebuild its whole context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proof for every test.&lt;/strong&gt; Each test a PR adds or changes needs a line in the
PR body naming the code change that turns it red. A PR without those lines
goes back before any reviewer spends a token on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't prove
&lt;/h2&gt;

&lt;p&gt;It isn't a controlled experiment. I chose which tasks went to Opus, the sample&lt;br&gt;
is small (19 Opus 5.5 PRs), and list prices are a stand-in for subscription&lt;br&gt;
usage. I started a fair split, with every other lane on Opus 5.5 regardless of&lt;br&gt;
the task, and stopped it early: the like-for-like comparison and the token&lt;br&gt;
counts pointed the same way. Opus 5.5 was cheaper, probably around half, and at&lt;br&gt;
worst no more expensive.&lt;/p&gt;

&lt;p&gt;One gap favours Opus in these numbers. In at least 3 of the 8 Opus PRs that&lt;br&gt;
failed their first review, the lead agent made the fix itself, so that fix's&lt;br&gt;
cost is missing from Opus's total. That happened in only 1 of 89 Sonnet cases.&lt;/p&gt;

&lt;p&gt;The lesson I'd keep even if the model prices change next month: in agent work,&lt;br&gt;
you pay for steps and context, not for tokens. Measure calls and context per&lt;br&gt;
unit of work, and the right model is usually obvious.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
