<?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: Diven Rastdus</title>
    <description>The latest articles on DEV Community by Diven Rastdus (@astraedus).</description>
    <link>https://dev.to/astraedus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3807319%2F88334a5b-4b5d-412a-a196-402f05bca721.png</url>
      <title>DEV Community: Diven Rastdus</title>
      <link>https://dev.to/astraedus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/astraedus"/>
    <language>en</language>
    <item>
      <title>5 New Postgres 19 Features in 2026 (And 2 That Got Pulled Before Release)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 28 Sep 2026 10:12:38 +0000</pubDate>
      <link>https://dev.to/astraedus/5-new-postgres-19-features-in-2026-and-2-that-got-pulled-before-release-5c18</link>
      <guid>https://dev.to/astraedus/5-new-postgres-19-features-in-2026-and-2-that-got-pulled-before-release-5c18</guid>
      <description>&lt;p&gt;If you read a "what's new in Postgres 19" post this summer, two of its headline features are gone. SQL/PGQ graph queries and &lt;code&gt;FOR PORTION OF&lt;/code&gt; temporal updates were both reverted in September, weeks before release. &lt;a href="https://www.postgresql.org/about/news/postgresql-19-beta-4-released-3386/" rel="noopener noreferrer"&gt;Beta 4&lt;/a&gt; shipped on September 24, the release candidate is due in early October, and GA may follow the same month. Here's what actually made it, the five I'd use in an app: &lt;code&gt;INSERT ... ON CONFLICT DO SELECT&lt;/code&gt;, &lt;code&gt;IGNORE NULLS&lt;/code&gt; in window functions, &lt;code&gt;WAIT FOR LSN&lt;/code&gt;, &lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt;, and &lt;code&gt;pg_plan_advice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nspvjf7mrhg8x23glgn.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%2F6nspvjf7mrhg8x23glgn.png" alt="Postgres 19 at Beta 4: five features shipping, two pulled, and the timeline to GA" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I run a handful of consumer apps on hosted Postgres. Every &lt;code&gt;supabase/config.toml&lt;/code&gt; I own still says &lt;code&gt;major_version = 17&lt;/code&gt;, a year after Postgres 18 went GA, because Supabase's 18 rollout is still not done. RDS had 18 within seven weeks and Neon had a preview the next day, so your lag depends on your host. For a lot of us a "what's new in 19" post is really a "what to plan for" post. I read the &lt;a href="https://www.postgresql.org/docs/19/release-19.html" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; and all four beta announcements so you can skim this instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. INSERT ... ON CONFLICT DO SELECT ends the get-or-create dance
&lt;/h2&gt;

&lt;p&gt;You can now insert a row or fetch the existing one in a single atomic statement, and &lt;code&gt;RETURNING&lt;/code&gt; hands you the row either way. Before 19, "get or create" meant one of two hacks. Either a fake update (&lt;code&gt;DO UPDATE SET id = excluded.id&lt;/code&gt;) that wrote WAL and left a dead tuple behind for nothing, or two round trips with a race between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three rules from the &lt;a href="https://www.postgresql.org/docs/19/sql-insert.html" rel="noopener noreferrer"&gt;INSERT docs&lt;/a&gt;. &lt;code&gt;RETURNING&lt;/code&gt; is mandatory, unlike &lt;code&gt;DO NOTHING&lt;/code&gt; and &lt;code&gt;DO UPDATE&lt;/code&gt;. A conflict target is mandatory. You need &lt;code&gt;SELECT&lt;/code&gt; privilege on the table. You can also add &lt;code&gt;DO SELECT FOR UPDATE&lt;/code&gt; to lock the existing row, which is what you want when the next statement modifies it. That variant also needs &lt;code&gt;UPDATE&lt;/code&gt; privilege on at least one column.&lt;/p&gt;

&lt;p&gt;Someone will suggest the CTE: &lt;code&gt;WITH ins AS (INSERT ... ON CONFLICT DO NOTHING RETURNING *) SELECT * FROM ins UNION ALL SELECT ...&lt;/code&gt;. That races too. A row committed by another session after your snapshot started isn't visible to the fallback &lt;code&gt;SELECT&lt;/code&gt;, so under load you get zero rows back. &lt;code&gt;DO SELECT&lt;/code&gt; reads the conflicting row directly.&lt;/p&gt;

&lt;p&gt;My signup trigger does &lt;code&gt;insert into profiles ... on conflict (id) do nothing&lt;/code&gt;. When the row already exists, &lt;code&gt;DO NOTHING&lt;/code&gt; returns nothing, so every caller that needs the row does a second &lt;code&gt;SELECT&lt;/code&gt;. In 19 that's one honest statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. IGNORE NULLS finally works in window functions
&lt;/h2&gt;

&lt;p&gt;"Carry forward the last non-null value" no longer needs a self-join, a correlated subquery, or a custom aggregate. Postgres 19 adds the SQL-standard &lt;a href="https://www.postgresql.org/docs/19/functions-window.html" rel="noopener noreferrer"&gt;null treatment clause&lt;/a&gt; to &lt;code&gt;lag&lt;/code&gt;, &lt;code&gt;lead&lt;/code&gt;, &lt;code&gt;first_value&lt;/code&gt;, &lt;code&gt;last_value&lt;/code&gt;, and &lt;code&gt;nth_value&lt;/code&gt;. The default stays &lt;code&gt;RESPECT NULLS&lt;/code&gt;, so nothing changes until you ask.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;last_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IGNORE&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;
         &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;CURRENT&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt;
       &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;price_filled&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;daily_prices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Oracle, Snowflake, and DuckDB have had this for years. It matters for any sparse time series: sensor readings, price ticks, feature flags that only log changes. Mood trackers are the classic case. People skip logging on weekends, and every chart wants the last known value carried forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. WAIT FOR LSN gives you read-your-writes on a replica
&lt;/h2&gt;

&lt;p&gt;After a write on the primary, capture the write-ahead log position, then make the replica wait for it before you read. The classic bug this kills: a user saves, the page reloads, the read hits a replica that is 40 ms behind, and the user sees their old data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- On the primary, right after the commit&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_current_wal_insert_lsn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;-- 0/0306EE20&lt;/span&gt;

&lt;span class="c1"&gt;-- On the replica, before the read&lt;/span&gt;
&lt;span class="n"&gt;WAIT&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;LSN&lt;/span&gt; &lt;span class="s1"&gt;'0/0306EE20'&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TIMEOUT&lt;/span&gt; &lt;span class="s1"&gt;'200ms'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NO_THROW&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stash the LSN from the write response in the session or a cookie, and send it with the next read. &lt;a href="https://www.postgresql.org/docs/19/sql-wait.html" rel="noopener noreferrer"&gt;&lt;code&gt;WAIT FOR&lt;/code&gt;&lt;/a&gt; returns &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, or &lt;code&gt;not in recovery&lt;/code&gt;, so your app can fall back to the primary when the replica is too far behind. Restrictions worth knowing: it must be a top-level command (no functions or &lt;code&gt;DO&lt;/code&gt; blocks), and it has to be the first statement of a transaction or run outside one. It is also rejected outright if your session already holds a lock and the target LSN hasn't arrived. The &lt;code&gt;MODE&lt;/code&gt; option picks what you wait for. &lt;code&gt;standby_replay&lt;/code&gt; is the default and the one that gives you read-your-writes. &lt;code&gt;standby_flush&lt;/code&gt; waits only until the WAL is durable on the replica, and &lt;code&gt;standby_write&lt;/code&gt; only until it is received.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. REPACK CONCURRENTLY rebuilds a bloated table without the long lock
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/19/sql-repack.html" rel="noopener noreferrer"&gt;&lt;code&gt;REPACK&lt;/code&gt;&lt;/a&gt; unifies &lt;code&gt;VACUUM FULL&lt;/code&gt; and &lt;code&gt;CLUSTER&lt;/code&gt; into one command (the old ones stay for compatibility), and &lt;code&gt;CONCURRENTLY&lt;/code&gt; takes the &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock only for the final file swap. Under the hood it uses logical decoding to capture the writes that land during the rebuild and applies them before the swap. It's pg_repack, in core.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;REPACK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;events_created_at_idx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the caveats before you run it on a hot table. The docs say plainly that &lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt; is not MVCC-safe. It needs free disk at least equal to the table plus its indexes, and a bit more to buffer the concurrent writes. The table needs a primary key or an index-based replica identity, and it can't be partitioned, unlogged, or a materialized view. It can't run inside a transaction block, DDL on the table from another session can make it fail, and you need the &lt;code&gt;MAINTAIN&lt;/code&gt; privilege. &lt;code&gt;VACUUM FULL&lt;/code&gt; blocks reads and writes for the entire rewrite, which on a 50 GB events table is a very long outage. I'd upgrade for this alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. pg_plan_advice: plan hints, the sanctioned kind
&lt;/h2&gt;

&lt;p&gt;Postgres has resisted Oracle-style query hints for about twenty years. &lt;a href="https://www.postgresql.org/docs/19/pgplanadvice.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_plan_advice&lt;/code&gt;&lt;/a&gt; is the closest thing now in contrib. It's a loadable module that prints the advice string reproducing the plan you have. Set that string and the planner is steered toward that plan while you fix the real cause.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="s1"&gt;'pg_plan_advice'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COSTS&lt;/span&gt; &lt;span class="k"&gt;OFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PLAN_ADVICE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- the plan output now includes the advice that reproduces it&lt;/span&gt;

&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;pg_plan_advice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'JOIN_ORDER(o c) HASH_JOIN(c)'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advice vocabulary covers join order, join method, scan method, and parallelism: &lt;code&gt;JOIN_ORDER&lt;/code&gt;, &lt;code&gt;HASH_JOIN&lt;/code&gt;, &lt;code&gt;NESTED_LOOP_PLAIN&lt;/code&gt;, &lt;code&gt;SEQ_SCAN&lt;/code&gt;, &lt;code&gt;INDEX_SCAN&lt;/code&gt;, &lt;code&gt;NO_GATHER&lt;/code&gt;. Two honest limits. Advice can only pick among plans the planner already considers, and a &lt;code&gt;SET&lt;/code&gt; only affects your own session. For the app's connections you either apply it with &lt;code&gt;ALTER DATABASE ... SET pg_plan_advice.advice = ...&lt;/code&gt;, or use the companion &lt;a href="https://www.postgresql.org/docs/19/pgstashadvice.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_stash_advice&lt;/code&gt;&lt;/a&gt; extension, which stores advice per query id and applies it automatically. Turn on &lt;code&gt;pg_plan_advice.feedback_warnings&lt;/code&gt; and the planner tells you when it couldn't follow your advice. The 3 a.m. use case is a plan that flips after an &lt;code&gt;ANALYZE&lt;/code&gt; on a table that just crossed a size threshold. Stash it, page nobody, fix the statistics in the morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2 that got pulled before release
&lt;/h2&gt;

&lt;h3&gt;
  
  
  SQL/PGQ graph queries (reverted September 7)
&lt;/h3&gt;

&lt;p&gt;Beta 1 shipped &lt;code&gt;CREATE PROPERTY GRAPH&lt;/code&gt; and &lt;code&gt;GRAPH_TABLE&lt;/code&gt;, the SQL:2023 way to run graph queries over ordinary tables. Friends-of-friends, dependency chains, and permission graphs in plain SQL with no Neo4j. It was reverted by its own committer, Peter Eisentraut, with unresolved problems: &lt;code&gt;DROP TABLE ... CASCADE&lt;/code&gt; left orphaned graph metadata, label scoping was wrong, and &lt;code&gt;pg_dump&lt;/code&gt; hit dependency loops with materialized views that queried &lt;code&gt;GRAPH_TABLE&lt;/code&gt;. Tom Lane's &lt;a href="https://thebuild.com/blog/19th-nervous-breakdown/" rel="noopener noreferrer"&gt;take on the risk&lt;/a&gt;: "I'd be willing to bet dinner that if we ship it in v19 there will be post-release bug discoveries that are unfixable until v20." The earliest it can return is Postgres 20, expected around September 2027.&lt;/p&gt;

&lt;h3&gt;
  
  
  FOR PORTION OF temporal updates (reverted September 15)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;UPDATE prices FOR PORTION OF valid_range FROM '2026-07-01' TO '2026-10-01' SET price = 34.99&lt;/code&gt; would have split a row automatically and completed the SQL:2011 temporal feature set. It was pulled for a concurrency anomaly at &lt;code&gt;READ COMMITTED&lt;/code&gt;, the default isolation level. Two sessions updating overlapping ranges could leave the second one silently updating zero rows. The feature's own documentation described the problem and prescribed an explicit row lock as the workaround, and the community chose to revert rather than ship with a footnote. Dimitri Fontaine has the &lt;a href="https://tapoueh.org/blog/2026/09/getting-ready-for-postgresql-19/" rel="noopener noreferrer"&gt;full walkthrough&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Also pulled this cycle: &lt;code&gt;GROUP BY ALL&lt;/code&gt; (reverted in July, listed in the Beta 3 notes), &lt;code&gt;ALTER TABLE ... MERGE/SPLIT PARTITIONS&lt;/code&gt; (August 27, the second time it has been pulled before release, after Postgres 17 in 2024), and online data checksums (September 16). Five feature reverts in one beta cycle is unusual, and I read it as the project protecting its five-year support promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defaults that change under you
&lt;/h2&gt;

&lt;p&gt;None of these need new SQL. All of them can surprise you on upgrade day. Every one is listed in the &lt;a href="https://www.postgresql.org/docs/19/release-19.html" rel="noopener noreferrer"&gt;migration section&lt;/a&gt; of the release notes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JIT is off by default.&lt;/strong&gt; If a reporting query got faster from JIT, it gets slower again unless you turn it back on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_locks_per_transaction&lt;/code&gt; doubles from 64 to 128.&lt;/strong&gt; An explicit setting keeps your old value, so check your config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;log_lock_waits&lt;/code&gt; is on by default.&lt;/strong&gt; Expect more log volume on a contended database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;default_toast_compression&lt;/code&gt; becomes &lt;code&gt;lz4&lt;/code&gt;.&lt;/strong&gt; Faster, slightly larger, and it only affects newly compressed values. Existing TOAST data stays as it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RADIUS authentication is gone&lt;/strong&gt; and &lt;code&gt;md5&lt;/code&gt; logins now warn on every successful login. Move to &lt;code&gt;scram-sha-256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;standard_conforming_strings&lt;/code&gt; can no longer be turned off.&lt;/strong&gt; Old dumps that relied on it won't load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;btree_gist&lt;/code&gt; indexes on &lt;code&gt;inet&lt;/code&gt; and &lt;code&gt;cidr&lt;/code&gt; are flagged as broken.&lt;/strong&gt; GiST is the new default opclass for those types. &lt;code&gt;pg_upgrade&lt;/code&gt; disallows any cluster that still has one of the old indexes, so rebuild them first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;json_array()&lt;/code&gt; over zero rows returns &lt;code&gt;[]&lt;/code&gt;, not &lt;code&gt;NULL&lt;/code&gt;.&lt;/strong&gt; Any &lt;code&gt;IS NULL&lt;/code&gt; check or &lt;code&gt;coalesce&lt;/code&gt; on that result changes behavior silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database, role, and tablespace names can't contain a carriage return or line feed.&lt;/strong&gt; &lt;code&gt;pg_upgrade&lt;/code&gt; rejects them, so rename first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;If you're on Supabase you probably won't run 19 in production before well into 2027. On RDS or Neon it could arrive within weeks of GA. Either way, you can prep now. Diff the defaults list against your &lt;code&gt;postgresql.conf&lt;/code&gt;, because every one of them is a config change you can stage in advance. Then find your get-or-create paths and your fill-forward queries and mark them, because those are the first two things to rewrite the day your host offers 19. And try &lt;code&gt;ON CONFLICT DO SELECT&lt;/code&gt; against a copy of your schema on the beta, not in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pg19 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pg &lt;span class="nt"&gt;-p&lt;/span&gt; 5433:5432 postgres:19beta4
psql &lt;span class="nt"&gt;-h&lt;/span&gt; localhost &lt;span class="nt"&gt;-p&lt;/span&gt; 5433 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fomy3s57h0zsilh82xa84.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%2Fomy3s57h0zsilh82xa84.png" alt="Postgres 19 upgrade checklist: the defaults and removals to check before you upgrade" width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Give Your App a Review Monitor That Actually Works (Google Play and App Store, Only New Reviews)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Thu, 24 Sep 2026 17:07:01 +0000</pubDate>
      <link>https://dev.to/astraedus/how-to-give-your-app-a-review-monitor-that-actually-works-google-play-and-app-store-only-new-2mmg</link>
      <guid>https://dev.to/astraedus/how-to-give-your-app-a-review-monitor-that-actually-works-google-play-and-app-store-only-new-2mmg</guid>
      <description>&lt;p&gt;A review monitor that actually works has four parts: a public source for each store, a stable review ID to de-duplicate on, a memory of what it already sent you, and a delivery channel that isn't your inbox. Get those four right and "only the new reviews, both stores, no server" is a daily schedule, not a project. Most review scrapers get one of them wrong, usually the memory, so they hand you the same 200 reviews every morning and you stop reading.&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%2Fwebh8a77x4cyayle47tj.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%2Fwebh8a77x4cyayle47tj.png" alt="The four parts of a review monitor: public sources, normalize, memory keyed on review IDs, webhook delivery, with a guard for empty feeds" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the stores' own tools are not enough
&lt;/h2&gt;

&lt;p&gt;The stores tell you about reviews late, partially, or only for apps you own. Google Play's Developer API returns reviews for your own apps only, and only the ones with text from roughly the last week. Apple's App Store Connect API needs your own keys and never covers a competitor. If you want "every new review on both stores, mine and the two apps I compete with, the morning it lands", nothing in the consoles does it.&lt;/p&gt;

&lt;p&gt;I ship three Android apps, and a review is usually the first signal that something broke or that a feature is wanted. A five-star review on one of them asked for a date-format setting, and it shipped in the next release. A three-star "It doesn't work sometimes" on another was the first report of a bug. The gap between "a review landed" and "a human read it" is the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: where the reviews actually come from
&lt;/h2&gt;

&lt;p&gt;Both stores expose public review data, just not through an API you'd enjoy. Google Play has no public reviews API at all. The store page loads its reviews through an internal &lt;code&gt;batchexecute&lt;/code&gt; endpoint, which is what every Play scraper on npm calls under the hood (&lt;code&gt;google-play-scraper&lt;/code&gt; is the well-known one). Apple is friendlier. There is a public RSS feed per app and country, newest first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://itunes.apple.com/us/rss/customerreviews/page=1/id=324684580/sortby=mostrecent/json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It serves at most 10 pages of 50 reviews, so 500 per app per country, and it carries no developer replies. Google Play does carry replies, and it dropped review titles years ago, so &lt;code&gt;title&lt;/code&gt; is always null there. Your normalized row has to accept both shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"store"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google-play"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dev.astraedus.nudge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6ca5d4fd-5ef6-49bc-877e-159f062a5a91"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"It doesn't work sometimes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-06T09:09:52.941Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.15.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"developerReply"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isNew"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 2: the de-duplication key is the whole product
&lt;/h2&gt;

&lt;p&gt;The review ID is the only stable thing. Both stores give every review an ID that survives edits, rating changes and re-sorting, so "new" means "an ID I haven't emitted before", never "dated after my last run". Dates fail in two ways: users edit old reviews (new date, same review), and stores serve pages out of order under load. Keep a set of emitted IDs per app and country, and diff against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// state key: `${store}:${appId}:${country}` -&amp;gt; string[] of emitted review IDs&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;newReviews&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KVStore&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;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&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;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reviewId&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;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reviewId&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// first run: the backlog; every run after: only the delta&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run per app is the baseline, so cap it (200 is plenty). Every later run returns only the delta, which is what makes a daily digest readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: don't believe an empty answer
&lt;/h2&gt;

&lt;p&gt;"No new reviews" and "the store wouldn't serve the feed" look identical unless you check. Apple's public feed periodically answers 200 OK with zero reviews for every app, which is rate limiting wearing a valid response. Recording that as "nothing new" costs you nothing in data, since the IDs were never emitted. It costs you the truth: the monitor now believes it looked when it didn't. The cheap guard is to cross-check the app's public rating count: an app with 4,000 ratings and an empty feed is a store problem, not a quiet week. Log three different empties, and never collapse them into one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;this app has no reviews yet&lt;/li&gt;
&lt;li&gt;this app ID doesn't exist in this storefront&lt;/li&gt;
&lt;li&gt;the store declined right now, retry later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never tell yourself "no new reviews" when the truth is "we could not look".&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: deliver to a webhook, not a dashboard
&lt;/h2&gt;

&lt;p&gt;A monitor you have to open is a dashboard, and dashboards don't get opened. Post one JSON summary per run to a webhook and let Slack, Telegram, Zapier or n8n do the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"runAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-24T08:00:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"appsChecked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"newReviews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"store"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google-play"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"appId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.example.app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"newCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"avgRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lowestReviews"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Crashes on launch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://play.google.com/store/apps/details?id=com.example.app"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter before you post. Alerting only on ratings of 2 and below means every message is worth reading, and a webhook that's down should be logged and ignored, never allowed to fail a run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it daily with no server
&lt;/h2&gt;

&lt;p&gt;The scheduler is the only part that has to live somewhere other than your laptop, and a GitHub Actions cron is free. A workflow on a daily schedule, &lt;code&gt;google-play-scraper&lt;/code&gt; plus the Apple feed, and a committed &lt;code&gt;seen.json&lt;/code&gt; as the ID set is about a hundred lines. Commit the state file back at the end of the run and your memory survives the ephemeral runner, which is the only trick in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node monitor.js&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name github-actions&lt;/span&gt;
          &lt;span class="s"&gt;git commit -am "reviews: update seen set" || exit 0&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also packaged the four parts as an Apify actor, &lt;a href="https://apify.com/astraedus/app-review-monitor" rel="noopener noreferrer"&gt;App Store and Google Play Review Monitor&lt;/a&gt;, if you'd rather not own the cron. Same four parts, same empty-feed guard, pay-per-event, a couple of dollars a month at five apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The hard part of a review monitor is not fetching reviews. It's remembering what you already saw and refusing to trust an empty page. Build the memory around review IDs, treat "no reviews" as a claim to verify, and push the result somewhere you already look. Then the monitor keeps working on the day you stop thinking about it, which is the only day that matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>ios</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>TypeScript vs JSDoc vs JavaScript: Which Should You Use in 2026</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 21 Sep 2026 10:12:22 +0000</pubDate>
      <link>https://dev.to/astraedus/typescript-vs-jsdoc-vs-javascript-which-should-you-use-in-2026-50f3</link>
      <guid>https://dev.to/astraedus/typescript-vs-jsdoc-vs-javascript-which-should-you-use-in-2026-50f3</guid>
      <description>&lt;p&gt;Use TypeScript for anything that another file, another person, or an AI agent will import. Use plain JavaScript for the rest. JSDoc with &lt;code&gt;// @ts-check&lt;/code&gt; covers the ground in between. That's the whole answer. It's a cleaner answer in 2026 than it was, because both classic objections to TypeScript are dead. Node runs &lt;code&gt;.ts&lt;/code&gt; files directly, so there's no build step. TypeScript 7 shipped in July as a native Go port, so the compiler is about ten times faster. What's left is a judgement about boundaries, not about tooling.&lt;/p&gt;

&lt;p&gt;I ship both languages, sometimes in the same repository. Three sibling Apify actors live in one monorepo. One is strict TypeScript with &lt;code&gt;noUncheckedIndexedAccess&lt;/code&gt; on, one is strict TypeScript with it off, and one is 22 files of plain JavaScript. None of that was taste. Each one got the amount of checking its data earns. Here's the rule I use, and the 2026 facts that make it hold.&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%2Fhimw3a62rakkohmeqgnw.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%2Fhimw3a62rakkohmeqgnw.png" alt="Where TypeScript's cost went: in 2020 the compiler sat on the run path; in 2026 the runtime strips types and the compiler only checks" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in 2026?
&lt;/h2&gt;

&lt;p&gt;The cost side of the TypeScript trade collapsed. Three releases did it, and the last two landed this year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/" rel="noopener noreferrer"&gt;TypeScript 6.0 shipped in March&lt;/a&gt;&lt;/strong&gt; as the last release built on the JavaScript codebase. It turned &lt;code&gt;strict&lt;/code&gt; on by default and started retiring &lt;code&gt;target: es5&lt;/code&gt; along with the AMD and UMD module formats. Start a project today and you get strict mode without asking for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/" rel="noopener noreferrer"&gt;TypeScript 7.0 shipped on July 8&lt;/a&gt;&lt;/strong&gt; as the native Go port, the one that spent a year as "Project Corsa". Microsoft's numbers on real repositories: VS Code type-checks 11.9x faster, Sentry 8.9x, Playwright 8.7x. A check that took a minute now takes seconds. The catch as of 7.0 is that the stable programmatic API isn't there yet, so Vue, Svelte, Astro and Angular template tooling stay on 6.x until 7.1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node stopped needing a build.&lt;/strong&gt; &lt;a href="https://nodejs.org/api/typescript.html" rel="noopener noreferrer"&gt;Type stripping&lt;/a&gt; went on by default in Node 23.6, was backported to 22.18, and reached stable in 24.12 in December 2025. It's a 2025 milestone that every LTS line carries in 2026. This runs today on Node 24:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node app.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's one constraint. Stripping erases annotations and never generates code, so enums, namespaces with runtime code, parameter properties and &lt;code&gt;import =&lt;/code&gt; are out. TypeScript has a flag that rejects exactly that syntax at check time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tsconfig.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"erasableSyntaxOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verbatimModuleSyntax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"esnext"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deno and Bun ran &lt;code&gt;.ts&lt;/code&gt; natively already. So the "TypeScript means a compile step" argument is gone on every mainstream runtime. The compiler still exists, but it moved off the run path. It checks in your editor and in CI, and it emits nothing.&lt;/p&gt;

&lt;p&gt;What didn't die is the other cost. A dependency with bad or missing types still drags you into &lt;code&gt;any&lt;/code&gt; casts, and a clever generic written by someone else is a worse read than the JavaScript it replaced. That cost is real and it isn't going anywhere. It just isn't a build-step argument any more.&lt;/p&gt;

&lt;p&gt;One more data point. GitHub's &lt;a href="https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/" rel="noopener noreferrer"&gt;Octoverse 2025 report&lt;/a&gt; put TypeScript at number one on the platform by monthly contributors, ahead of Python and JavaScript, and tied the jump to AI-assisted coding. A type checker reads every line an agent writes. No human reviewer does that.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does TypeScript pay for itself?
&lt;/h2&gt;

&lt;p&gt;TypeScript pays for itself the moment a data shape crosses a boundary: an API response, a message bus, storage, a form, a config file. Inside one function, types are documentation. Across a boundary, they are a contract that a machine enforces.&lt;/p&gt;

&lt;p&gt;My clearest example is a Chrome extension, &lt;a href="https://github.com/astraedus/nudge" rel="noopener noreferrer"&gt;Nudge&lt;/a&gt;, an open-source app blocker. A content script talks to a service worker through &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt;, and that API is typed &lt;code&gt;any&lt;/code&gt; in both directions. In the first version, a handler that returned the wrong shape produced &lt;code&gt;undefined&lt;/code&gt; three screens away, at runtime, on someone else's machine.&lt;/p&gt;

&lt;p&gt;The fix was a discriminated union for requests and a map from request type to response type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET_BLOCK_CONTEXT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMPLETE_PAUSE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ADD_SITE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SiteMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;delaySeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET_SETTINGS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ResponseMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;GET_BLOCK_CONTEXT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BlockContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;COMPLETE_PAUSE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GrantResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ADD_SITE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;GET_SETTINGS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NudgeSettings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ResponseFor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ResponseMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Request&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;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="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="nx"&gt;ResponseFor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&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;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ResponseFor&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;send({ type: 'GET_SETTINGS' })&lt;/code&gt; resolves to &lt;code&gt;NudgeSettings&lt;/code&gt; and nothing else. Add a request variant without a response entry and the build fails. The mistake moved from a user's runtime to my editor, in twenty lines.&lt;/p&gt;

&lt;p&gt;The cast in &lt;code&gt;send&lt;/code&gt; is load-bearing and deliberate. &lt;code&gt;sendMessage&lt;/code&gt; returns &lt;code&gt;any&lt;/code&gt;, so one assertion in one wrapper buys type safety at every call site. That's the trade, made once, in a file I can audit.&lt;/p&gt;

&lt;p&gt;The second setting worth its cost is &lt;a href="https://www.typescriptlang.org/tsconfig/noUncheckedIndexedAccess.html" rel="noopener noreferrer"&gt;&lt;code&gt;noUncheckedIndexedAccess&lt;/code&gt;&lt;/a&gt;. It makes &lt;code&gt;arr[i]&lt;/code&gt; and &lt;code&gt;map[key]&lt;/code&gt; type as &lt;code&gt;T | undefined&lt;/code&gt;, which is what they are. This line looks the way it does because the compiler refused the version without the &lt;code&gt;?? 0&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="nx"&gt;hourly&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hourly&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JavaScript the missing hour is &lt;code&gt;undefined&lt;/code&gt;, adding to it gives &lt;code&gt;NaN&lt;/code&gt;, and the chart renders a gap that nobody reports. Of my three actors, the one with this flag off is not a decision. It's an oversight, and it's now a ticket.&lt;/p&gt;

&lt;p&gt;Types stop at the runtime boundary, though. A &lt;code&gt;fetch&lt;/code&gt; response typed as &lt;code&gt;Review&lt;/code&gt; is still whatever the server sent. I covered that gap in &lt;a href="https://astraedus.dev/blog/typescript-gotcha-that-breaks-production" rel="noopener noreferrer"&gt;the TypeScript gotcha that silently breaks production&lt;/a&gt;. Validate at the boundary, then trust the types inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is plain JavaScript the right call?
&lt;/h2&gt;

&lt;p&gt;Plain JavaScript is the right call when nothing else imports the code. The script that adds anchor links and JSON-LD to my blog posts is 116 lines of Node. One author, one caller, one job. A &lt;code&gt;tsconfig.json&lt;/code&gt; would be the second largest file in the directory. It runs, and it's done.&lt;/p&gt;

&lt;p&gt;The plain-JS actor is the same shape at a larger size: a scraper with a single output schema, 22 files, JSDoc comments for the next reader, no checker. It has been fine. The honest caveat is that "fine" holds only until a second consumer shows up. The day another actor imports its normaliser, it gets a tsconfig, because at that moment the shape crosses a boundary.&lt;/p&gt;

&lt;p&gt;The signal is not line count. It is the number of things that can be wrong about a shape without you noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the middle: JSDoc and @ts-check?
&lt;/h2&gt;

&lt;p&gt;There's a third option most comparisons skip. The TypeScript compiler &lt;a href="https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html" rel="noopener noreferrer"&gt;will check a &lt;code&gt;.js&lt;/code&gt; file&lt;/a&gt; if you ask, with no rename and no build step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @ts-check&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * @param {Record&amp;lt;string, number&amp;gt;} hourly
 * @param {number} hour
 * @param {number} seconds
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hourly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;hourly&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hourly&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same checker, same strict flags through &lt;code&gt;checkJs&lt;/code&gt; in tsconfig, and the file stays JavaScript. Svelte's core team &lt;a href="https://www.devclass.com/development/2023/05/11/typescript-is-not-worth-it-for-developing-libraries-says-svelte-author-as-team-switches-to-javascript-and-jsdoc/1630004" rel="noopener noreferrer"&gt;moved the compiler's internals to this style in 2023&lt;/a&gt; because the tooling friction of a non-standard language wasn't worth it for library code. The team has debated revisiting that call since, but hasn't reversed it. The public API still ships &lt;code&gt;.d.ts&lt;/code&gt; types. It's the right answer for glue code that outgrew "just run it" but has not earned a build. It also suits library authors who want the debugger to show the file they wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision rule for 2026
&lt;/h2&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%2Fxs2f95i68q0dt8hdk4jj.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%2Fxs2f95i68q0dt8hdk4jj.png" alt="Decision tree: will anything import it, does a shape cross a boundary, will it outlive the quarter" width="799" height="652"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Will anything else import it?&lt;/strong&gt; No: plain JavaScript. Run it and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does a data shape cross a boundary?&lt;/strong&gt; Yes: TypeScript with &lt;code&gt;strict&lt;/code&gt; and &lt;code&gt;noUncheckedIndexedAccess&lt;/code&gt;, plus runtime validation at the edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neither, but it will outlive the quarter?&lt;/strong&gt; Then TypeScript anyway, because it will grow a boundary. Otherwise JSDoc with &lt;code&gt;// @ts-check&lt;/code&gt; until it does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The question was never "TypeScript or JavaScript". It was "how much checking does this code earn". In 2026 the checking is free at runtime and cheap at build time, so the only honest reason to skip it is that there's nothing for it to catch.&lt;/p&gt;

&lt;p&gt;If you want one thing to do today: open a repo you own and find the first untyped boundary, an API response, a message handler, a config read. Give it a type, validate the input at the edge, and turn on &lt;code&gt;noUncheckedIndexedAccess&lt;/code&gt; for that package. That one boundary will tell you whether the rest of the repo has earned it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>5 Production Failures AI Coding Agents Cause (And the Checks That Catch Them)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 18 Sep 2026 10:11:44 +0000</pubDate>
      <link>https://dev.to/astraedus/5-production-failures-ai-coding-agents-cause-and-the-checks-that-catch-them-4kc0</link>
      <guid>https://dev.to/astraedus/5-production-failures-ai-coding-agents-cause-and-the-checks-that-catch-them-4kc0</guid>
      <description>&lt;p&gt;AI coding agents rarely ship code that fails the check you gave them. They ship code that passes the check while the thing you cared about is broken. The five failures below all went green through unit tests and CI, then broke on a real device, a real store build, or a real user. The fix was the same every time: move the check from the input the agent controls to the artifact the user runs.&lt;/p&gt;

&lt;p&gt;For eight weeks my Play Store build shipped the wrong JavaScript. The manifest was right, CI was green, the env var was set, and the Hermes bundle inside the AAB had the same md5 as the one inside the debug APK. I ship agent-written code to production on two open-source Android apps and a pile of automation scripts. For the last few months I logged every bug that passed review and then broke on a real user. Almost none of them were "the agent wrote wrong code." They were "the agent wrote the code and the test to match, and both agreed with each other while disagreeing with reality." Here are the five patterns, with the incident that taught me each one.&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%2Fb7c7lj7khc0336s1rodt.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%2Fb7c7lj7khc0336s1rodt.png" alt="Five failures: what the check verified versus where the bug actually lived" width="800" height="491"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The permission that was requested but never declared
&lt;/h2&gt;

&lt;p&gt;An agent will add a runtime permission request and forget the manifest declaration, and Android fails that combination silently. No error, no prompt, just empty data.&lt;/p&gt;

&lt;p&gt;The mood tracker I maintain reads heart rate variability from Health Connect. The agent added the HRV record type to the runtime &lt;code&gt;requestPermission()&lt;/code&gt; set and shipped. Users reported "there is no HRV view." The view existed. It was starved of data. A health permission that is not declared in the manifest is never offered to the user. It never appears in &lt;code&gt;getGrantedPermissions()&lt;/code&gt;, so code that reads only the granted record types quietly skips it. The metric comes back empty with no exception in sight.&lt;/p&gt;

&lt;p&gt;That's one fact with two write sites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check:&lt;/strong&gt; an invariant test that locks them together. Swap in your own two constants.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// __tests__/healthPermissionInvariant.test.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;REQUIRED_READ_RECORD_TYPES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;OPTIONAL_READ_RECORD_TYPES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&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;RECORD_TYPE_TO_PERMISSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manifest permissions equal the runtime request set, exactly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;HEALTH_PERMISSIONS&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtime&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;Add a record type on one side and the suite fails until the other side matches. Any OS permission that is requested at runtime has this second write site. Lock them.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The transaction that wrapped nothing
&lt;/h2&gt;

&lt;p&gt;When a fix depends on a library contract, the agent will honor the shape of the API and ignore the contract, and a mock can't tell the difference.&lt;/p&gt;

&lt;p&gt;The same app moved its writes into expo-sqlite's &lt;code&gt;withExclusiveTransactionAsync&lt;/code&gt;. The agent ran every statement on the outer &lt;code&gt;db&lt;/code&gt; handle instead of the &lt;code&gt;txn&lt;/code&gt; argument the callback receives. That API opens a separate connection for the transaction. The &lt;code&gt;txn&lt;/code&gt; argument was the transaction. The outer &lt;code&gt;db&lt;/code&gt; was not. So a real &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt; ran on one connection, with nothing inside them, while the writes ran unprotected in autocommit on the other. No lock was ever taken, either. Nothing ran on &lt;code&gt;txn&lt;/code&gt;, so the transaction never escalated to a write transaction, and the outer connection sailed through in autocommit.&lt;/p&gt;

&lt;p&gt;Jest mocked the transaction helper as "just run the callback," so the tests passed. Device QA passed too, because an unrelated change had hidden the visible symptom. The fix stayed "verified" for two weeks while users kept hitting state bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check:&lt;/strong&gt; static. Scan the source and ban the outer handle inside write callbacks. This is the shape of the real test, with a brace-naive regex standing in for the helper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// writeTransactionInvariant.test.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writeCallbackBodies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/withWrite&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;Transaction|Lock&lt;/span&gt;&lt;span class="se"&gt;)\(\s&lt;/span&gt;&lt;span class="sr"&gt;*async&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\)\s&lt;/span&gt;&lt;span class="sr"&gt;*=&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)\n\}\)&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&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;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;WRITE_MODULES&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%s never touches the outer db inside a write callback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stripComments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;writeCallbackBodies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;db&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;runAsync|execAsync|getAllAsync|getFirstAsync&lt;/span&gt;&lt;span class="se"&gt;)\(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the mechanism of a fix is a callback argument or a handle, read the library source and assert the contract. The symptom disappearing proves nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The tests that defended the bug
&lt;/h2&gt;

&lt;p&gt;The suite had 400 green tests, and two of them asserted the exact bug.&lt;/p&gt;

&lt;p&gt;The browser extension of my app blocker had a rule engine ported from Android. A hard-block rule with a daily time limit fell through the branch chain to &lt;code&gt;ALLOW&lt;/code&gt;. On Android that was a harmless no-op. In the browser the declarativeNetRequest redirect had already fired, so &lt;code&gt;ALLOW&lt;/code&gt; became an infinite redirect loop.&lt;/p&gt;

&lt;p&gt;The spec had said "a hard block with a limit must not block via the unconditional branch." The agent did what the spec said and wrote two tests to prove it. Only a live browser session with a user-authored config caught it. If the spec describes which branch should run, the agent writes tests that assert the branch, and the suite defends the bug forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check:&lt;/strong&gt; an invariant over the whole input space, not a regression for the instance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;an applicable rule can NEVER produce ALLOW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HARD_BLOCK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELAY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BREATHING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;LIMITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;USAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&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;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&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;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;MODES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;LIMITS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;USAGES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`blocks: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dailyLimitMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BLOCK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's thirty cases from one sentence of intent. Phrase tests as user-visible outcomes. A test that names an internal branch is a test that will guard whatever that branch does.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The suite that collected zero tests
&lt;/h2&gt;

&lt;p&gt;A dead test suite stays invisible for as long as every run is scoped to one file.&lt;/p&gt;

&lt;p&gt;One script in my tools directory called &lt;code&gt;sys.exit(2)&lt;/code&gt; at import time. Pytest hit it during collection and the whole directory stopped collecting. To be fair to pytest, a bare run is loud about this: it exits 3 when collection blows up and 5 when nothing is collected. Nobody ran it bare. Every agent run for 71 days executed a scoped command like &lt;code&gt;pytest tests/test_one_thing.py&lt;/code&gt;, which collected fine and exited 0. The gate was green because the gate was never pointed at the whole suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check:&lt;/strong&gt; run the whole suite on a schedule, and read the count, not just the exit code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pytest &lt;span class="nt"&gt;--collect-only&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; tests 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;COLLECTED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'^[0-9]+ tests? collected'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'^[0-9]+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;COLLECTED&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 50 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"collection broken: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;COLLECTED&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; tests (floor 50)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any gate needs a floor, and a scoped run is not a gate. The floor is the number you'd be embarrassed to fall under.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The build that asserted the input, not the artifact
&lt;/h2&gt;

&lt;p&gt;The agent will verify the env var, the log line, and the manifest, and never open the bundle the user runs.&lt;/p&gt;

&lt;p&gt;The mood tracker builds two Android variants from one tree in one CI job. An env knob drives two layers: a config plugin strips the health permissions from the manifest, and Babel inlines the same knob into the JavaScript so the feature card hides. CI asserted the manifest both ways and stopped.&lt;/p&gt;

&lt;p&gt;For eight weeks the Play build shipped with the permissions excluded from the manifest and the feature enabled in the JavaScript. Tapping the card called a native module with no permission delegate registered, and the release build died. The cause was three layers down, and it plays out as a timeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Metro's transform cache key hashes the transformer files and the config. It doesn't hash env values. (Expo has since added cache-vary handling for some &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; vars, so check your SDK, but upstream Metro still works this way.)&lt;/li&gt;
&lt;li&gt;The cache lives in the OS temp dir, so it survives &lt;code&gt;expo prebuild --clean&lt;/code&gt; and both Gradle runs of one job.&lt;/li&gt;
&lt;li&gt;The APK step ran first and warmed the cache with the "enabled" output.&lt;/li&gt;
&lt;li&gt;The AAB step got cache hits for every file, including the one whose output depended on the env.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One command settled it: the Hermes bundle inside the AAB had the same md5 as the bundle inside the APK.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check:&lt;/strong&gt; bake a marker into the code and read it out of each built artifact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;AAB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;android/app/build/outputs/bundle/release/app-release.aab
&lt;span class="nv"&gt;APK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;android/app/build/outputs/apk/release/app-release.apk
unzip &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; base/assets/index.android.bundle &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; aab.bundle   &lt;span class="c"&gt;# AAB: base/ prefix&lt;/span&gt;
unzip &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; assets/index.android.bundle      &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; apk.bundle   &lt;span class="c"&gt;# APK: no prefix&lt;/span&gt;
&lt;span class="nv"&gt;EN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;strings aab.bundle | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'hc-variant:enabled'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;EX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;strings aab.bundle | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'hc-variant:excluded'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"::error::Play bundle is the wrong variant"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;md5sum &lt;/span&gt;aab.bundle | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;md5sum &lt;/span&gt;apk.bundle | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assert the artifact. An env var, a log line saying the env was set, and a manifest grep are inputs. The thing the user runs is the bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern under all five
&lt;/h2&gt;

&lt;p&gt;An AI coding agent optimizes for the check you hand it, so the check has to point at the thing you would bet the deploy on.&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%2Fsrodmbuob1oc9qa2ofqs.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%2Fsrodmbuob1oc9qa2ofqs.png" alt="The five checks, and which failure each one catches" width="799" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every one of these bugs lived on the far side of a boundary the checks never crossed. Types, unit tests, and CI exit codes are cheap, and the agent will make all of them green. The manifest on the device, the bundle in the store, the library's real connection handling, and the real user config are expensive, and that's where the bugs waited. The five checks above are each a few lines. What they share is the target. The artifact instead of the input. The outcome instead of the branch. And the whole class of inputs, not the one instance that happened to break on a Tuesday.&lt;/p&gt;

&lt;p&gt;If you review agent-written code, add one question to the review. What would have to be true for this test to pass while the feature is broken? If the answer is easy to state, that's the next check to write.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>5 New Python 3.15 Features in 2026 (And 2 I'm Still Waiting For)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 11 Sep 2026 10:16:03 +0000</pubDate>
      <link>https://dev.to/astraedus/5-new-python-315-features-in-2026-and-2-im-still-waiting-for-4g56</link>
      <guid>https://dev.to/astraedus/5-new-python-315-features-in-2026-and-2-im-still-waiting-for-4g56</guid>
      <description>&lt;p&gt;&lt;code&gt;lazy import&lt;/code&gt; cut one of my CLI tools from 89 ms to 26 ms of startup. One keyword, no other changes.&lt;/p&gt;

&lt;p&gt;Python 3.15 ships on October 1, and that's one of five changes that reach the code you write every day. In July I wrote up &lt;a href="https://dev.to/astraedus/5-python-314-features-that-change-how-you-write-code-in-2026-and-2-im-still-waiting-for-30mn"&gt;the 3.14 features that changed how I write code&lt;/a&gt; and ended with two things I was still waiting for. So I installed the release candidate with &lt;code&gt;uv python install 3.15.0rc2&lt;/code&gt;, ran every example below on it, and kept the numbers.&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%2Fgoxnyhr6jdfpw3ejwzmm.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%2Fgoxnyhr6jdfpw3ejwzmm.png" alt="Eager vs lazy imports: what runs before your code does" width="799" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;lazy import&lt;/code&gt; makes slow CLI startup a solved problem
&lt;/h2&gt;

&lt;p&gt;Put &lt;code&gt;lazy&lt;/code&gt; in front of a module-level import and Python binds the name immediately but doesn't load the module until the first time you touch it. That's &lt;a href="https://peps.python.org/pep-0810/" rel="noopener noreferrer"&gt;PEP 810&lt;/a&gt;. It's the one feature I'd upgrade for on its own.&lt;/p&gt;

&lt;p&gt;Here's the shape of half the CLI tools I maintain. Fifteen stdlib imports at the top, and a &lt;code&gt;main()&lt;/code&gt; that on most runs touches one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cli_tool.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;http.client&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;xml.etree.ElementTree&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tomllib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;
&lt;span class="c1"&gt;# ... 7 more
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ready&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefix every import with &lt;code&gt;lazy&lt;/code&gt; and nothing else changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lazy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;
&lt;span class="n"&gt;lazy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="n"&gt;lazy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="c1"&gt;# ... same for the rest
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten runs of each on 3.15.0rc2, median wall time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;script&lt;/th&gt;
&lt;th&gt;modules loaded by the time &lt;code&gt;main()&lt;/code&gt; returns&lt;/th&gt;
&lt;th&gt;startup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eager imports&lt;/td&gt;
&lt;td&gt;185&lt;/td&gt;
&lt;td&gt;89 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lazy&lt;/code&gt; imports&lt;/td&gt;
&lt;td&gt;37 (all of them argparse, the one module &lt;code&gt;main()&lt;/code&gt; uses)&lt;/td&gt;
&lt;td&gt;26 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bare &lt;code&gt;python -c pass&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;13 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The eager version spends about 63 ms loading fourteen modules the run never touches. The lazy version still pays for argparse, because &lt;code&gt;main()&lt;/code&gt; calls it, and pays for &lt;code&gt;sqlite3&lt;/code&gt; only on the run that calls &lt;code&gt;sqlite3.connect()&lt;/code&gt;. You can watch it happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="n"&gt;lazy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# False
&lt;/span&gt;&lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:memory:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules are strict. The errors are good, though. &lt;code&gt;lazy&lt;/code&gt; works at module scope only. Inside a function, a class body, or a &lt;code&gt;try&lt;/code&gt; block it's a &lt;code&gt;SyntaxError&lt;/code&gt;. So are &lt;code&gt;lazy from x import *&lt;/code&gt; and &lt;code&gt;lazy from __future__ import&lt;/code&gt;. If a lazily imported module doesn't exist, the &lt;code&gt;ImportError&lt;/code&gt; surfaces at first use, and the traceback shows both the import line and the line that triggered it.&lt;/p&gt;

&lt;p&gt;Two switches matter for real projects. &lt;code&gt;python -X lazy_imports=all&lt;/code&gt; (or &lt;code&gt;PYTHON_LAZY_IMPORTS=all&lt;/code&gt;) makes every module-level import lazy without touching source, and gave me the same 26 ms. For a library that must still import on 3.14, declare &lt;code&gt;__lazy_modules__ = ["json", "pathlib"]&lt;/code&gt; at the top of the module. Older Pythons ignore the name and import eagerly.&lt;/p&gt;

&lt;p&gt;One warning: keep imports with side effects eager. Anything that registers a plugin, configures logging, or patches a module on import must not be deferred, or it silently never runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Unpacking works inside comprehensions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;[*sub for sub in lists]&lt;/code&gt; flattens a list of lists, and it's the fastest way to do it. &lt;a href="https://peps.python.org/pep-0798/" rel="noopener noreferrer"&gt;PEP 798&lt;/a&gt; allows &lt;code&gt;*&lt;/code&gt; and &lt;code&gt;**&lt;/code&gt; inside list, set, and dict comprehensions, which turns the double-&lt;code&gt;for&lt;/code&gt; idiom into one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&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="n"&gt;sub&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lists&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                  &lt;span class="c1"&gt;# [1, 2, 3, 4, 5]  (new)
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lists&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="c1"&gt;# the 3.14 way
&lt;/span&gt;
&lt;span class="n"&gt;dicts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dicts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;                     &lt;span class="c1"&gt;# {'a': 3, 'b': 2}, last write wins
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flattening 1,000 lists of 20 ints with &lt;code&gt;timeit&lt;/code&gt;, best of five runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;idiom&lt;/th&gt;
&lt;th&gt;time per flatten&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[x for sub in lists for x in sub]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;201 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list(itertools.chain.from_iterable(lists))&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;169 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[*sub for sub in lists]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;73 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's faster because the interpreter extends the result list once per inner list instead of appending one element at a time. On 3.12 and 3.14 the same line is &lt;code&gt;SyntaxError: iterable unpacking cannot be used in comprehension&lt;/code&gt;, so this is a 3.15-only idiom, not a backport candidate.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;frozendict&lt;/code&gt; is a built-in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;frozendict&lt;/code&gt; is an immutable, hashable mapping you can use as a dict key, a cache key, or a config object nobody can mutate by accident. It's &lt;a href="https://peps.python.org/pep-0814/" rel="noopener noreferrer"&gt;PEP 814&lt;/a&gt; and needs no import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozendict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;frozendict&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="nb"&gt;object&lt;/span&gt; &lt;span class="n"&gt;does&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;frozendict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;        &lt;span class="c1"&gt;# order doesn't matter
&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pool-1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6543&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;frozendict&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;host&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6543&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;frozendict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;                        &lt;span class="c1"&gt;# values must be hashable too
&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;unhashable&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;list&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing that will bite you: &lt;code&gt;frozendict&lt;/code&gt; isn't a subclass of &lt;code&gt;dict&lt;/code&gt;. &lt;code&gt;isinstance(cfg, dict)&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;. Check against &lt;code&gt;collections.abc.Mapping&lt;/code&gt; instead, which also covers &lt;code&gt;MappingProxyType&lt;/code&gt;. The stdlib already accepts it where you'd expect, including &lt;code&gt;json.dumps&lt;/code&gt;, &lt;code&gt;copy&lt;/code&gt;, &lt;code&gt;pickle&lt;/code&gt;, and &lt;code&gt;pprint&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A profiler that attaches to a live process
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;python -m profiling.sampling attach &amp;lt;PID&amp;gt;&lt;/code&gt; profiles a running Python process from the outside, with no code change, no restart, and no slowdown in the target. The tool is called Tachyon (&lt;a href="https://peps.python.org/pep-0799/" rel="noopener noreferrer"&gt;PEP 799&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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcw2bhvqcldjb615qc0i1.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%2Fcw2bhvqcldjb615qc0i1.png" alt="Tachyon reads a live process from outside it" width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's a separate process that reads the target's memory and rebuilds the stack, so the target pays nothing. Four subcommands, plus a &lt;code&gt;--live&lt;/code&gt; flag for a top-style view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; profiling.sampling run hot.py            &lt;span class="c"&gt;# run a script under the profiler&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; profiling.sampling attach 4242           &lt;span class="c"&gt;# attach to a live PID&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; profiling.sampling attach &lt;span class="nt"&gt;--live&lt;/span&gt; 4242    &lt;span class="c"&gt;# top-style TUI on a live PID&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; profiling.sampling dump 4242             &lt;span class="c"&gt;# one stack snapshot, no profiling&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; profiling.sampling replay profile.bin    &lt;span class="c"&gt;# convert a saved profile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two seconds against a script with two hot loops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Captured 2,001 samples in 2.00 seconds
Sample rate: 1,000.45 samples/sec

  nsamples  sample%  tottime (ms)  cumul%  cumtime (s)  filename:lineno(function)
   943/943     47.1       943.000    47.1        0.943  hot.py:5(hash_loop)
   780/780     39.0       780.000    39.0        0.780  hot.py:11(parse_loop)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;--flamegraph&lt;/code&gt; for a self-contained HTML flame graph, &lt;code&gt;--gecko&lt;/code&gt; for the Firefox Profiler, &lt;code&gt;--mode gil&lt;/code&gt; to see who holds the GIL, or &lt;code&gt;--async-aware&lt;/code&gt; for asyncio code. The docs quote rates up to 1,000,000 samples per second; the default 1,000 nailed the split above.&lt;/p&gt;

&lt;p&gt;One Linux gotcha: &lt;code&gt;attach&lt;/code&gt; reads another process's memory, so a stock Ubuntu (&lt;code&gt;kernel.yama.ptrace_scope=1&lt;/code&gt;) refuses it even for your own processes. The error message tells you the fixes: &lt;code&gt;sudo -E&lt;/code&gt;, &lt;code&gt;echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope&lt;/code&gt;, or &lt;code&gt;--cap-add=SYS_PTRACE&lt;/code&gt; in Docker. &lt;code&gt;run&lt;/code&gt; mode needs none of that. The old deterministic profiler now lives at &lt;code&gt;profiling.tracing&lt;/code&gt;, and &lt;code&gt;cProfile&lt;/code&gt; still imports.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;sentinel&lt;/code&gt; kills the &lt;code&gt;object()&lt;/code&gt; trick
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MISSING = sentinel("MISSING")&lt;/code&gt; gives you a unique "no argument was passed" marker with a readable repr, and it's a built-in (&lt;a href="https://peps.python.org/pep-0661/" rel="noopener noreferrer"&gt;PEP 661&lt;/a&gt;). Every codebase I've touched reinvents this with a bare &lt;code&gt;object()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pickle&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MISSING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sentinel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MISSING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MISSING&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;     &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;MISSING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;         &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;     &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MISSING&lt;/span&gt;
&lt;span class="n"&gt;MISSING&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;MISSING&lt;/span&gt;                                  &lt;span class="c1"&gt;# valid in a type annotation
&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;MISSING&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pickle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pickle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MISSING&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;MISSING&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identity survives pickling and copying, which the &lt;code&gt;object()&lt;/code&gt; trick never did, and the repr says &lt;code&gt;MISSING&lt;/code&gt; instead of &lt;code&gt;&amp;lt;object object at 0x7f...&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smaller wins I already use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;open("notes.txt")&lt;/code&gt; is UTF-8 on every platform now (&lt;a href="https://peps.python.org/pep-0686/" rel="noopener noreferrer"&gt;PEP 686&lt;/a&gt;), so the "written on Linux, read on Windows as cp1252" bug is gone. &lt;code&gt;PYTHONUTF8=0&lt;/code&gt; if a legacy pipeline needs the old behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;asyncio.TaskGroup.cancel()&lt;/code&gt; ends a group early without try/except boilerplate. I use it for "take the first result, drop the rest".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;os.makedirs(path, parent_mode=0o755)&lt;/code&gt; and &lt;code&gt;Path.mkdir(parent_mode=...)&lt;/code&gt; set a separate mode for the intermediate directories.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;json.loads(text, array_hook=tuple)&lt;/code&gt; turns JSON arrays into whatever you want, the way &lt;code&gt;object_hook&lt;/code&gt; always did for objects.&lt;/li&gt;
&lt;li&gt;Error messages now speak other languages: &lt;code&gt;[1, 2].push(3)&lt;/code&gt; says &lt;code&gt;Did you mean '.append'?&lt;/code&gt;, and &lt;code&gt;(1, 2).append(3)&lt;/code&gt; asks whether you wanted a list.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;argparse --help&lt;/code&gt;, &lt;code&gt;timeit&lt;/code&gt;, &lt;code&gt;ast&lt;/code&gt;, &lt;code&gt;sqlite3&lt;/code&gt;, and &lt;code&gt;http.server&lt;/code&gt; output got colour, and &lt;code&gt;difflib.unified_diff()&lt;/code&gt; grew a &lt;code&gt;color=True&lt;/code&gt; parameter for git-style diffs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'm still waiting for
&lt;/h2&gt;

&lt;p&gt;Both wishes from the 3.14 post are closer, and neither has arrived.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The JIT on by default.&lt;/strong&gt; The 3.15 JIT is a real upgrade. The What's New page reports pyperformance results of roughly 8 to 9 percent geometric-mean speedup on x86-64 Linux over the standard interpreter. On AArch64 macOS it's 12 to 13 percent over the already faster tail-calling interpreter. The page flags both numbers as not final. It still ships off. The official Windows and macOS binaries build it in, but you opt in with &lt;code&gt;PYTHON_JIT=1&lt;/code&gt;. &lt;a href="https://peps.python.org/pep-0836/" rel="noopener noreferrer"&gt;PEP 836&lt;/a&gt;, still a draft, proposes the bar the JIT would have to clear to stop being experimental. I want the release where I forget the environment variable exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free-threading as the plain &lt;code&gt;python&lt;/code&gt;.&lt;/strong&gt; The no-GIL build is still a separate build in 3.15. What landed is the plumbing: &lt;a href="https://peps.python.org/pep-0803/" rel="noopener noreferrer"&gt;PEP 803&lt;/a&gt; defines a stable ABI for free-threaded builds (&lt;code&gt;abi3t&lt;/code&gt;), so extension authors can ship one wheel that works there. The What's New page also notes that setuptools, meson-python, scikit-build-core, and Maturin don't support &lt;code&gt;abi3t&lt;/code&gt; yet. When they do, the wheel ecosystem can catch up, and then the default build can change. Not this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;Install the release candidate (&lt;code&gt;uv python install 3.15.0rc2&lt;/code&gt;) and run your test suite against it. When it passes, make three changes in this order. Add &lt;code&gt;lazy&lt;/code&gt; to the heavy imports in every CLI entry point and measure the startup. Replace your flatten idioms with &lt;code&gt;[*sub for sub in lists]&lt;/code&gt;. Switch your immutable config objects to &lt;code&gt;frozendict&lt;/code&gt;. Then, the next time a process is slow in production, attach Tachyon to it before you touch the code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>performance</category>
      <category>news</category>
    </item>
    <item>
      <title>How MCP Actually Works: The Protocol Behind Every AI Agent Integration</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:15:07 +0000</pubDate>
      <link>https://dev.to/astraedus/how-mcp-actually-works-the-protocol-behind-every-ai-agent-integration-38ja</link>
      <guid>https://dev.to/astraedus/how-mcp-actually-works-the-protocol-behind-every-ai-agent-integration-38ja</guid>
      <description>&lt;p&gt;MCP isn't a framework, an SDK, or an AI feature. It's JSON-RPC 2.0 sent over stdio or HTTP, where every request carries its own protocol version and capabilities. That's the entire protocol, and you can hold all of it in your head at once.&lt;/p&gt;

&lt;p&gt;I run several MCP servers in production and I've written both sides of the wire. The protocol took an afternoon to learn. The failures took considerably longer, and none of them are in the quickstart. So this is the protocol on one page, followed by the five things that broke for me after it was supposedly working.&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%2Fnfjujjzysjgch37voqq8.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%2Fnfjujjzysjgch37voqq8.png" alt="MCP architecture: one client per server, and no session between them" width="800" height="564"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is
&lt;/h2&gt;

&lt;p&gt;MCP is a client-host-server protocol where one host runs many clients, and each client talks to exactly one server. The architecture page is blunt about the ratio: "each client having a 1:1 relationship with a particular server." Your host app (Claude Code, Cursor, your own agent loop) holds the model. Connect five servers and you've got five clients inside one host.&lt;/p&gt;

&lt;p&gt;That rule explains API design that otherwise looks like an omission. There's no routing layer, no server registry, no addressing scheme on the wire. A message on a connection is unambiguously for that server, so nothing needs to say which server it means.&lt;/p&gt;

&lt;p&gt;One more principle worth knowing: servers can't read the whole conversation or see into each other. Isolation between servers is the host's job, not the protocol's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the current revision
&lt;/h2&gt;

&lt;p&gt;The current revision, &lt;code&gt;2026-07-28&lt;/code&gt;, deleted the &lt;code&gt;initialize&lt;/code&gt; handshake and made MCP stateless. The architecture page states it directly: "MCP is a stateless protocol: every request is self-contained and carries its own protocol version and capabilities." This was well covered when it landed, so treat it as context rather than news. It also silently invalidates a lot of older tutorials.&lt;/p&gt;

&lt;p&gt;There's no session to open. Every request carries &lt;code&gt;io.modelcontextprotocol/protocolVersion&lt;/code&gt; and &lt;code&gt;io.modelcontextprotocol/clientCapabilities&lt;/code&gt; in its &lt;code&gt;_meta&lt;/code&gt; field, and the server accepts or rejects that request on its own. Send an unsupported version and you get back error &lt;code&gt;-32022&lt;/code&gt; listing the versions the server does support, so you retry with one of those. If you'd rather ask up front, servers &lt;strong&gt;MUST&lt;/strong&gt; implement &lt;code&gt;server/discover&lt;/code&gt;; calling it is optional.&lt;/p&gt;

&lt;p&gt;The spec calls the old world "legacy" (&lt;code&gt;2025-11-25&lt;/code&gt; and earlier) and the new one "modern". Mixed eras fail unless one side implements both, so if you're staring at a dead connection, check that first.&lt;/p&gt;

&lt;p&gt;The second breaking change got much less attention: &lt;strong&gt;servers can no longer initiate JSON-RPC requests at all.&lt;/strong&gt; The spec is flat about it, "servers do not initiate JSON-RPC requests and clients do not send JSON-RPC responses." Anything a server used to ask the client for, sampling, elicitation, roots, now comes back inside a reply as an &lt;code&gt;InputRequiredResult&lt;/code&gt; with &lt;code&gt;resultType: "input_required"&lt;/code&gt;. The client answers by retrying the original call with &lt;code&gt;inputResponses&lt;/code&gt; and the server's opaque &lt;code&gt;requestState&lt;/code&gt;, using a different JSON-RPC &lt;code&gt;id&lt;/code&gt;. If you wrote a server that calls back into the model, it's broken, and it probably fails quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two transports, and only two
&lt;/h2&gt;

&lt;p&gt;The spec defines exactly two standard transports. &lt;strong&gt;stdio&lt;/strong&gt; launches the server as a subprocess and exchanges newline-delimited JSON-RPC over its standard streams. &lt;strong&gt;Streamable HTTP&lt;/strong&gt; POSTs each message to a single endpoint, and replies arrive either as a JSON object or a request-scoped SSE stream.&lt;/p&gt;

&lt;p&gt;The older two-endpoint HTTP+SSE transport has been deprecated since &lt;code&gt;2025-03-26&lt;/code&gt;. If a tutorial has you opening a long-lived &lt;code&gt;GET&lt;/code&gt; for events, that tutorial is old.&lt;/p&gt;

&lt;p&gt;Which transport you get comes down to which key you wrote. This shape is host-tooling convention, not protocol spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"filesystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@modelcontextprotocol/server-filesystem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/data"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.example.com/mcp"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;command&lt;/code&gt; key means a subprocess on your machine. A &lt;code&gt;url&lt;/code&gt; key means an HTTP request to someone else's. That difference matters more than it looks, because only one of them can read your environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a tool definition actually is
&lt;/h2&gt;

&lt;p&gt;A tool is a name, a description, and a JSON Schema for its arguments, plus optional extras like &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;outputSchema&lt;/code&gt;, &lt;code&gt;icons&lt;/code&gt;, and &lt;code&gt;annotations&lt;/code&gt;. In Python it's a decorator, and the docstring becomes the description:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;conversations_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List active messaging conversations across connected platforms.

    Args:
        platform: Filter by platform name (telegram, discord, slack, etc.)
        limit: Maximum number of conversations to return (default 50)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what took me too long to internalize: &lt;strong&gt;that description is prompt text.&lt;/strong&gt; It isn't documentation for a human reading your repo. It's serialized into the model's context and it's the only basis the model has for picking your tool over another. A vague description is a tool the model never calls. A description carrying &lt;code&gt;Example: "amoxicillin, ibuprofen"&lt;/code&gt; is a tool it calls correctly the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The round trip of one tool call
&lt;/h2&gt;

&lt;p&gt;The model picks a tool, the client sends &lt;code&gt;tools/call&lt;/code&gt;, the server runs your code, and the result goes back into context. Two details in that last step surprise people.&lt;/p&gt;

&lt;p&gt;A tool result carries both &lt;code&gt;content&lt;/code&gt; and &lt;code&gt;structuredContent&lt;/code&gt;. The first is what the model reads. The second is server-produced JSON for your code, validated against &lt;code&gt;outputSchema&lt;/code&gt; if you defined one. The spec says a tool returning structured content &lt;strong&gt;SHOULD&lt;/strong&gt; also serialize it into a text block. For a human-facing agent I usually don't, because paying context tokens for JSON syntax the model doesn't need adds up.&lt;/p&gt;

&lt;p&gt;Errors come in two flavours that behave nothing alike. A malformed request or unknown tool is a &lt;strong&gt;protocol error&lt;/strong&gt;, a normal JSON-RPC error. A failure inside your tool is a &lt;strong&gt;tool execution error&lt;/strong&gt;, returned as a successful result with &lt;code&gt;isError: true&lt;/code&gt;. The spec is explicit that clients &lt;strong&gt;SHOULD&lt;/strong&gt; hand those to the model so it can self-correct, which means your error strings are prompt text too. "Invalid input" teaches the model nothing. "drug_names must be comma-separated, you sent an array" gets a correct retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts that only break in production
&lt;/h2&gt;

&lt;p&gt;Five things broke for me after MCP was supposedly working: colliding tool names, schemas that aren't portable across model providers, a 2xx from something that isn't an MCP server, cached tokens that lie about their own expiry, and state that no longer survives between calls. Everything above works on the first try in a demo. These cost me real 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%2F0hdrsjczjkzjrei9tq7f.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%2F0hdrsjczjkzjrei9tq7f.png" alt="One tool call, end to end, and where it actually breaks" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool names aren't namespaced.&lt;/strong&gt; Uniqueness is scoped to a single server, so two servers can both export &lt;code&gt;search&lt;/code&gt;. The spec tells aggregating clients to "implement a disambiguation strategy such as prefixing tool names with a server identifier", and warns the server's own &lt;code&gt;name&lt;/code&gt; isn't guaranteed unique either. Mine registers everything as &lt;code&gt;mcp_{server}_{tool}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON Schema isn't portable across model providers.&lt;/strong&gt; One schema from one server doesn't just work everywhere. Some providers reject &lt;code&gt;#/definitions/...&lt;/code&gt; and need &lt;code&gt;#/$defs/...&lt;/code&gt;. Some return a 400 when &lt;code&gt;required&lt;/code&gt; names a property missing from &lt;code&gt;properties&lt;/code&gt;. Some reject nullable &lt;code&gt;anyOf&lt;/code&gt; unions in tool inputs. My client runs a normalization pass over every incoming schema, and that pass exists entirely because of provider-specific rejections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 2xx doesn't mean you reached an MCP server.&lt;/strong&gt; Point a &lt;code&gt;url&lt;/code&gt; at a normal web app and it answers HTML with a 200. The SDK then waits out the whole connect timeout before surfacing an opaque cancellation. A content-type preflight turns a 60 second mystery into a one second error, so check for &lt;code&gt;application/json&lt;/code&gt; or &lt;code&gt;text/event-stream&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cached tokens lie after a restart.&lt;/strong&gt; Load a token from disk and its expiry can come back unset, which reads as valid no matter how old it is. You ship a stale token, and the failure isn't always a clean 401. One provider of mine returned 200 with an application-level auth error in the body, invisible to the transport layer and indistinguishable from an empty result set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Statelessness is now your problem.&lt;/strong&gt; Since there's no session, a server can't relate one call to the next. Anything spanning calls, a cart, a browser context, a transaction, needs an explicit handle returned by one tool and passed as an argument to the next. The spec's design guidance is non-normative here but worth obeying: "a handle is a name, not a capability." Validate authorization against it on every call, or anyone who guesses a handle inherits that state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool descriptions are attack surface
&lt;/h2&gt;

&lt;p&gt;A server you didn't write controls text that lands in your model's context, and the model is expected to act on it. The spec's warning is unambiguous: clients &lt;strong&gt;MUST&lt;/strong&gt; consider tool annotations untrusted unless they come from trusted servers.&lt;/p&gt;

&lt;p&gt;My client scans incoming descriptions for the obvious shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_MCP_INJECTION_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore\s+(all\s+)?previous\s+instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you\s+are\s+now\s+a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;\s*system\s*&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;do\s+not\s+(tell|reveal)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# WARNING-level only: we log but do not block, since false
# positives would break legitimate MCP servers.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is the honest state of the art. We log, we don't block, because blocking on a regex breaks real servers. Detection isn't a solution here.&lt;/p&gt;

&lt;p&gt;What works is narrowing reach. A stdio server is a subprocess that inherits your environment by default, so every API key you hold is one &lt;code&gt;os.environ&lt;/code&gt; away from someone else's code. I pass an explicit allowlist instead (&lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;HOME&lt;/code&gt;, &lt;code&gt;USER&lt;/code&gt;, &lt;code&gt;LANG&lt;/code&gt;, the &lt;code&gt;XDG_*&lt;/code&gt; vars) and nothing more. I also strip credential-shaped patterns out of error text, since an error string is an easy way to leak a token into a transcript you later paste in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;MCP is small: JSON-RPC, per-request metadata, and a list of tools whose descriptions are prompts. Most of what goes wrong comes from treating it as bigger and more magical than that.&lt;/p&gt;

&lt;p&gt;Write tool descriptions as if the model is your only reader, because it is. Namespace your tool names yourself, since nothing else will. And treat every description from a server you didn't write as untrusted text that's about to enter your model's context, because that's exactly what it is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>agents</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building a Real-Time Dashboard with Python and FastAPI (No WebSockets)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 07 Sep 2026 10:19:51 +0000</pubDate>
      <link>https://dev.to/astraedus/building-a-real-time-dashboard-with-python-and-fastapi-no-websockets-1fo7</link>
      <guid>https://dev.to/astraedus/building-a-real-time-dashboard-with-python-and-fastapi-no-websockets-1fo7</guid>
      <description>&lt;p&gt;A real-time dashboard in Python doesn't need WebSockets. Dashboard data flows one way, from server to browser, and Server-Sent Events do exactly that over ordinary HTTP. One endpoint, no protocol upgrade, no extra dependency, and the browser handles reconnection itself. FastAPI already ships everything required.&lt;/p&gt;

&lt;p&gt;Most metrics start life as a file. You have counters in a database, or a JSONL file some job appends to, and to see what changed you re-run a script and read the tail. That works until you want to &lt;em&gt;watch&lt;/em&gt; it. Then you start refreshing, and refreshing is just polling done by a human.&lt;/p&gt;

&lt;p&gt;Here's the shape of the whole thing. One producer, many browsers, and one bounded queue per browser.&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%2F2w94el6bhj49km3483m9.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%2F2w94el6bhj49km3483m9.png" alt="Architecture of a FastAPI Server-Sent Events dashboard: a producer task publishes into a broker that fans out to one bounded asyncio queue per connected client, each feeding a StreamingResponse to a browser EventSource" width="799" height="570"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The complete runnable code, including the test suite, is &lt;a href="https://gist.github.com/astraedus/448d3c37c351017dd5156fc0c89809ab" rel="noopener noreferrer"&gt;in this gist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not WebSockets?
&lt;/h2&gt;

&lt;p&gt;A dashboard never talks back, so a WebSocket buys you a bidirectional channel you never use. You pay for it with a protocol upgrade, a separate deployment story, and reconnection logic you write yourself. SSE is plain HTTP. It passes through proxies, it works with the auth middleware you already have, and reconnection is a browser feature rather than your code.&lt;/p&gt;

&lt;p&gt;Reach for WebSockets when the client genuinely sends messages: chat, collaborative editing, multiplayer. For numbers on a screen, SSE is less machinery for the same result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan out with one bounded queue per client
&lt;/h2&gt;

&lt;p&gt;Give every connected browser its own bounded &lt;code&gt;asyncio.Queue&lt;/code&gt; and drop the oldest message when it fills, so one slow client can never stall the producer. The load-bearing word is &lt;em&gt;bounded&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asynccontextmanager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suppress&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncIterator&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTMLResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StreamingResponse&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Broker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Pub/sub fan-out. Every subscriber owns a BOUNDED queue, so a slow
    browser tab can never apply backpressure to the producer.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_maxsize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxsize&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_subscribers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;subscriber_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_subscribers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Never awaits. If a subscriber is full, evict its oldest message
        (stale metrics are worthless) and count the drop.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_subscribers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueueFull&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_nowait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AsyncIterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_maxsize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_subscribers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_subscribers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# a disconnect always cleans up
&lt;/span&gt;

&lt;span class="n"&gt;broker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Broker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;publish()&lt;/code&gt; never awaits, and that's the whole design. If you await a slow client, one laptop on hotel wifi backs up the producer and stalls every other viewer. Bounding the queue and dropping the oldest entry turns that outage into a counter. For metrics, that trade is obviously right, because nobody wants a stale number delivered late.&lt;/p&gt;

&lt;p&gt;Staying synchronous buys a second property for free. Because &lt;code&gt;publish()&lt;/code&gt; never yields control, no client can disconnect mid-broadcast and mutate the set underneath the loop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subscribe()&lt;/code&gt; is an async context manager, so a dropped connection always removes its queue. That matters more than it sounds. A leak here stays invisible until you've run for a week and every disconnected tab is still holding memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start the producer with lifespan, not on_event
&lt;/h2&gt;

&lt;p&gt;Start background producers in FastAPI's &lt;code&gt;lifespan&lt;/code&gt; context manager rather than &lt;code&gt;@app.on_event("startup")&lt;/code&gt;. If you pass &lt;code&gt;lifespan&lt;/code&gt;, the old startup and shutdown handlers never run at all. FastAPI's own docs are blunt here. "If you provide a &lt;code&gt;lifespan&lt;/code&gt; parameter," they warn, "&lt;code&gt;startup&lt;/code&gt; and &lt;code&gt;shutdown&lt;/code&gt; event handlers will no longer be called."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;metrics_producer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Broker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tick&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latency_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clients&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscriber_count&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="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lifespan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AsyncIterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;metrics_producer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CancelledError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;


&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lifespan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lifespan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap the random numbers for your real stats source. The cancel-and-suppress in the &lt;code&gt;finally&lt;/code&gt; block is what makes shutdown quiet instead of dumping a cancellation traceback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint, and what a frame actually looks like
&lt;/h2&gt;

&lt;p&gt;An SSE frame is just text with a blank line at the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;event_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AsyncIterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;broker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&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="mf"&gt;15.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: ping&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# comment frame: keeps idle proxies from reaping us
&lt;/span&gt;                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_disconnected&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# drops this one message; fine for metrics, not for orders
&lt;/span&gt;            &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;event: metrics&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;event_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep-alive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# nginx would otherwise buffer the stream
&lt;/span&gt;        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details carry the weight. A blank line terminates a frame. A line starting with &lt;code&gt;:&lt;/code&gt; is a comment, which is why the heartbeat is spelled &lt;code&gt;: ping&lt;/code&gt;. And &lt;code&gt;data:&lt;/code&gt; has to be a single line, which &lt;code&gt;json.dumps()&lt;/code&gt; guarantees for free.&lt;/p&gt;

&lt;p&gt;The timeout doubles as the heartbeat. A plain &lt;code&gt;await queue.get()&lt;/code&gt; gives you nowhere to emit a keepalive on an idle stream, so an idle connection sits silent until some proxy decides it's dead. Wrapping the get in &lt;code&gt;wait_for&lt;/code&gt; lets one loop handle both data and liveness.&lt;/p&gt;

&lt;p&gt;Here's what curl sees, at roughly one frame per second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;metrics&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tick"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;121.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;40.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"clients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;metrics&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;data:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tick"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;205.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;62.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"clients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The browser needs nine lines
&lt;/h2&gt;

&lt;p&gt;The browser side is nine lines: &lt;code&gt;new EventSource("/events")&lt;/code&gt;, one event listener, and no reconnect logic at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// EventSource reconnects on its own and sends the last `id:` back as a&lt;/span&gt;
&lt;span class="c1"&gt;// Last-Event-ID header, so the server *can* resume you, if it keeps a replay&lt;/span&gt;
&lt;span class="c1"&gt;// buffer. This one doesn't. It just picks up live.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;es&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;es&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;metrics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;es&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reconnecting...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No reconnect loop, no exponential backoff, no library. Kill the server and the numbers freeze while the label flips to &lt;code&gt;reconnecting...&lt;/code&gt;. Start it again and they resume.&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%2F7f2k37x596wqm9r60535.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%2F7f2k37x596wqm9r60535.png" alt="The running dashboard in a browser, showing live requests/sec, p50 latency, connected clients and an incrementing tick counter" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that silently break (hanging tests, dead producers, nginx, and the six-connection cap)
&lt;/h2&gt;

&lt;p&gt;Each of these looks like a bug in your own code. None of them are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your tests hang forever.&lt;/strong&gt; &lt;code&gt;httpx.ASGITransport&lt;/code&gt; buffers the entire response body before returning, so &lt;code&gt;client.stream("GET", "/events")&lt;/code&gt; against an endless stream never returns at all. Even &lt;code&gt;response.status_code&lt;/code&gt; is unreachable. It looks exactly like a deadlock in your own broker. Drive the ASGI app directly and cancel after N frames, or run a real server in a fixture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your producer never starts under test.&lt;/strong&gt; &lt;code&gt;ASGITransport&lt;/code&gt; doesn't run lifespan, so nothing publishes and every test reads zero frames. &lt;code&gt;TestClient&lt;/code&gt; does run it, but only as a context manager. A bare &lt;code&gt;TestClient(app)&lt;/code&gt; without &lt;code&gt;with&lt;/code&gt; skips lifespan too, and just as silently. If you export your &lt;code&gt;lifespan&lt;/code&gt; function you can enter it in a fixture with &lt;code&gt;async with lifespan(app):&lt;/code&gt; and skip the extra dependency. Migrating from &lt;code&gt;on_event&lt;/code&gt; to &lt;code&gt;lifespan&lt;/code&gt; changes test behaviour without a warning, which is the real trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nginx buffers your stream.&lt;/strong&gt; Every local curl looks perfect without &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;, and then production delivers your "real-time" events in batches every few KB. This one stays invisible until you deploy behind a proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You get six connections per domain.&lt;/strong&gt; Over HTTP/1.1 most browsers cap simultaneous connections to one domain at six, and that budget is shared across tabs. A few open dashboards can starve the rest of your app. Over HTTP/2 the negotiated stream limit defaults to around 100, so serving the thing over HTTP/2 makes the problem disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bound the queue, drop the oldest
&lt;/h2&gt;

&lt;p&gt;The streaming is never the hard part. Roughly 120 lines gets you a live dashboard, and most of it is ordinary FastAPI. The hard part is deciding what happens when one client can't keep up. Bound the queue, drop the oldest frame, count the drops. A number that arrives late is worse than a number that never arrives, because you'll trust it.&lt;/p&gt;

&lt;p&gt;Clone &lt;a href="https://gist.github.com/astraedus/448d3c37c351017dd5156fc0c89809ab" rel="noopener noreferrer"&gt;the gist&lt;/a&gt;, run &lt;code&gt;uvicorn app:app&lt;/code&gt;, and open two tabs to watch the client counter move. Then shorten the heartbeat interval and watch the bytes, rather than reading the code and assuming.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me there or at &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Give Your AI Agent a Context Budget That Actually Works</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 04 Sep 2026 10:15:32 +0000</pubDate>
      <link>https://dev.to/astraedus/how-to-give-your-ai-agent-a-context-budget-that-actually-works-18ok</link>
      <guid>https://dev.to/astraedus/how-to-give-your-ai-agent-a-context-budget-that-actually-works-18ok</guid>
      <description>&lt;p&gt;Your agent isn't running out of context. It's drowning in it, because the share of the window that still matters keeps shrinking while the window itself keeps growing.&lt;/p&gt;

&lt;p&gt;I've been running an agent continuously for months, and it fails in a very specific way. Give it a fresh session and one task, and it's sharp. Let the same session run for forty turns, and the quality falls off a cliff on work it handled fine an hour earlier. Nothing crashed. No limit was hit. It just got worse.&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%2F0ar1c9hymzfeb4mnyzzl.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%2F0ar1c9hymzfeb4mnyzzl.png" alt="Two agents over 40 turns: one with no budget falls to 15% relevant tokens, one on a budget holds 72%" width="799" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Shape is illustrative. The ratio is the point, not the exact percentages.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fix is to stop treating the window as one container and start treating it as a budget with three tiers. A small hot layer you re-send every turn. A warm layer scoped to the task in front of you. A cold layer that sits on disk and costs nothing until you query it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does an AI agent get worse the longer it runs?
&lt;/h2&gt;

&lt;p&gt;Because a conversation is append-only by default, and every token you added on turn 3 is still being re-sent on turn 40.&lt;/p&gt;

&lt;p&gt;The model doesn't forget the early context. It drowns in it. On turn 3, those three files you read were the entire point of the turn. By turn 40 you're on a different problem, and those files are still sitting there, competing for attention with the thing you actually need right now. Nothing removed them, because nothing was ever made responsible for removing them.&lt;/p&gt;

&lt;p&gt;That costs you twice. Quality drops, because attention is finite and you spent it on stale tool output. Cost climbs, because every turn re-sends the whole history. A conversation sitting at 200K tokens that runs 40 more turns bills those 200K tokens 40 more times. (Caching softens that bill. It doesn't fix the underlying problem, and I'll come back to why.)&lt;/p&gt;

&lt;p&gt;Notice what isn't happening in that chart. Neither window filled up. Both had room to spare. Fullness was never the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a context budget?
&lt;/h2&gt;

&lt;p&gt;A context budget splits everything the agent could know into three tiers, ranked by how often each one gets re-sent.&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%2F4afpmntd2pwcnwxsvprw.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%2F4afpmntd2pwcnwxsvprw.png" alt="The context budget: hot loaded every turn, warm loaded per task, cold on disk until queried" width="800" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The tiers aren't about importance. They're about frequency. Your decision log might be the most valuable text you own, and it still belongs in cold storage, because you need two lines of it once a week. The system prompt might be dull boilerplate, and it belongs in hot, because it shapes every single turn.&lt;/p&gt;

&lt;p&gt;One rule per tier follows from that split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: Cap the hot layer, and let a script enforce it
&lt;/h2&gt;

&lt;p&gt;Anything loaded on every turn needs a hard token ceiling checked by code, because going over budget usually truncates quietly instead of raising an error.&lt;/p&gt;

&lt;p&gt;Measure it with the real tokenizer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Anthropic&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;HOT_BUDGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;  &lt;span class="c1"&gt;# tokens re-sent on every single turn
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;

&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AGENT.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hot layer: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOT_BUDGET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;HOT_BUDGET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;over budget by &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;HOT_BUDGET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that in CI. The endpoint is free and sits on its own rate limit pool, so the check costs you nothing. A hot layer with no enforced ceiling grows every week, because adding one more rule always feels free in the moment.&lt;/p&gt;

&lt;p&gt;Don't reach for &lt;code&gt;tiktoken&lt;/code&gt; here. It's OpenAI's tokenizer, not Claude's, and there's no offline Claude tokenizer to swap in. It undercounts, and it undercounts worse on code than on prose. The count isn't even stable across Claude models. Anthropic's own migration guidance says the tokenizer introduced with Claude 4.7 can produce up to roughly a third more tokens on identical text. A budget you measured six months ago is a budget that's wrong today. Count against the model ID you actually ship.&lt;/p&gt;

&lt;p&gt;Silent truncation is the part that bites. My own always-loaded startup context runs under a hard character cap, and the harness that injects it drops anything past that cap without complaining. Go over, and you lose the tail. Nothing tells you. The rule that came out of that: adding to the hot layer is a spending decision. A new line earns its place only if it prevents more trouble than it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: Retrieve instead of re-reading
&lt;/h2&gt;

&lt;p&gt;Never load a whole file into context to recover one fact. Index it once, then query for the handful of lines that answer the question.&lt;/p&gt;

&lt;p&gt;SQLite bundles a full-text search engine, FTS5, compiled in by default on virtually every standard Python build. No new infrastructure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(path, body)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# rebuild, so a second run doesn't duplicate every row
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO notes VALUES (?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT path, snippet(notes, 1, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; ... &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, 20) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FROM notes WHERE notes MATCH ? ORDER BY rank LIMIT 5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry AND webhook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's standard library only. No vector database, no embedding bill, no service to keep alive. An FTS5 index over a few thousand markdown files answers in tens of milliseconds, fast enough to query mid-turn instead of guessing.&lt;/p&gt;

&lt;p&gt;Five snippets instead of five files. Reach for embeddings when you genuinely need semantic recall, but keyword search already answers most questions shaped like "what did I decide about X".&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: Make the warm layer someone else's problem
&lt;/h2&gt;

&lt;p&gt;When a task reads a lot but answers little, run it in a subagent. The reading lands in that agent's window, and only the conclusion lands in yours.&lt;/p&gt;

&lt;p&gt;The asymmetry is the whole trick. Reading forty files to answer one architectural question can run into six figures of tokens. The answer is 200 words. If your main loop does that reading, it carries every one of those tokens for the rest of the session. If a subagent does it, the main loop pays for 200 words.&lt;/p&gt;

&lt;p&gt;The dispatch needs an explicit return contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CONTRACT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Search the repo and answer the question below.

Return at most 200 words: the answer, the file:line references that support it,
and nothing else. Do not paste file contents back into your reply.
If the repo does not answer it, reply exactly: blocked: &amp;lt;what you need&amp;gt;.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters more than it looks. A vague subagent prompt comes back as a confident guess, and a confident wrong answer costs far more than the tokens you saved, because everything downstream gets built on it. Give the subagent the access to look things up itself, and give it explicit permission to come back empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can the API clear old context for me?
&lt;/h2&gt;

&lt;p&gt;Yes. Context editing strips stale tool results out of the history in place. It's the server-side cousin of the subagent trick:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-opus-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;betas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context-management-2025-06-27&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;context_management&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear_tool_uses_20250919&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]},&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[...],&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[...],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That clears old tool output rather than summarizing it, which is what you want when most of your history is tool results nobody will read again. There's a &lt;code&gt;clear_thinking_20251015&lt;/code&gt; strategy too. The bare form above runs on defaults, so set an explicit trigger threshold once you know what your loop actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: Keep durable state on disk, not in the conversation
&lt;/h2&gt;

&lt;p&gt;Anything that has to outlive the session belongs in a file, because the conversation is the one storage layer guaranteed to disappear.&lt;/p&gt;

&lt;p&gt;Here's the test I use. If this session died mid-task right now, could a fresh one pick the work up from what's on disk? If the answer is no, your state is sitting in the wrong tier.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;state/
  ACTIVE.md      # what I am doing now, and the next concrete step
  DECISIONS.md   # what I chose, and why (the why is the expensive part)
  LESSONS.md     # what broke, and the rule that stops a repeat
  notes.db       # the FTS5 index over all of it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;ACTIVE.md&lt;/code&gt; is hot. Everything else is cold and gets queried. Write the reasoning, not just the outcome, because the outcome is usually recoverable from the code and the reasoning never is. That's also where the compounding shows up: a lesson written down once becomes a rule that costs almost nothing to carry forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does prompt caching solve this instead?
&lt;/h2&gt;

&lt;p&gt;Caching makes re-sending a long prefix much cheaper, but it does nothing about relevance, so it fixes your bill and not your quality.&lt;/p&gt;

&lt;p&gt;It's still worth setting up properly. Caching is prefix matched, so any byte that changes invalidates everything after it. Keep stable content first (a frozen system prompt, a deterministically ordered tool list) and put volatile content (timestamps, request IDs, the varying question) after your last breakpoint. Then check &lt;code&gt;usage.cache_read_input_tokens&lt;/code&gt; on real traffic. If it reads zero across repeated requests, a &lt;code&gt;datetime.now()&lt;/code&gt; in the system prompt is the usual culprit. The other one is subtler: the minimum cacheable prefix depends on the model, so a prompt that's simply too short silently never caches at all.&lt;/p&gt;

&lt;p&gt;Real money, genuinely. But a cached irrelevant token is still an irrelevant token sitting in the window. Budget first, then cache what survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&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%2Flzrbg5clquthpso01fmh.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%2Flzrbg5clquthpso01fmh.png" alt="The context budget checklist: cap the hot layer, index it, isolate heavy reading, keep state on disk" width="799" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pick the rule your agent breaks worst and fix only that one this week. For most people it's the first one, because almost nobody has measured the layer they re-send a thousand times a day.&lt;/p&gt;

&lt;p&gt;Measure it once. You'll either find nothing, or you'll find the reason your agent goes stupid after lunch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
      <category>python</category>
    </item>
    <item>
      <title>How I replaced react-native-chart-kit with 1,355 lines of react-native-svg</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 04 Sep 2026 05:58:52 +0000</pubDate>
      <link>https://dev.to/astraedus/how-i-replaced-react-native-chart-kit-with-1355-lines-of-react-native-svg-c2i</link>
      <guid>https://dev.to/astraedus/how-i-replaced-react-native-chart-kit-with-1355-lines-of-react-native-svg-c2i</guid>
      <description>&lt;p&gt;Four separate complaints about my mood tracker's chart had one root cause: the charting library owned the geometry, and the geometry was the thing users were complaining about. So I deleted the dependency and drew the chart myself with &lt;code&gt;react-native-svg&lt;/code&gt;. The replacement is 1,355 lines of TypeScript across five files, 750 of them pure functions with no React imports at all.&lt;/p&gt;

&lt;p&gt;SoulSync is an open-source mood tracker on Android. Its Statistics tab started life on &lt;code&gt;react-native-chart-kit&lt;/code&gt;, the default answer to "how do I draw a line chart in React Native". It's a fine default right up until you want the chart to do something.&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%2F6ojite5knjn5lsu4pj9e.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%2F6ojite5knjn5lsu4pj9e.png" alt="The Statistics tab: monthly mood trend with a drawn 0 to 10 axis, mood-coloured line and a dashed 7-day trend overlay" width="700" height="1400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually wrong with the library chart
&lt;/h2&gt;

&lt;p&gt;Four defects: one flat stroke whatever the value, a bezier that overshot the data, a slot-indexed x axis, and no way to scrub it. Only the first is cosmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One flat stroke, whatever the value.&lt;/strong&gt; A mood of 3 and a mood of 8 were painted in the same colour at the same weight. Users kept saying the line was "not bright enough", which turned out to mean "I can't tell the good days from the bad ones".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bezier overshot the data.&lt;/strong&gt; A smooth curve between a 4 and a 9 dips below 4 on the way in. On a 0 to 10 mood scale that's a mood the user never had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The x axis was slot-indexed, not a time axis.&lt;/strong&gt; My query only returns days that were logged. Plot those consecutively and a three-month silence sits the same distance from its neighbour as a one-day silence. Worse, the "14-day moving average" I was labelling was really a 14-&lt;strong&gt;entry&lt;/strong&gt; average. It was quietly wrong on exactly the sparse data where a trend line matters most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No way in.&lt;/strong&gt; Users wanted to hold the chart and drag along it, reading each day as they went. 6.x does give you &lt;code&gt;onDataPointClick&lt;/code&gt; and a &lt;code&gt;decorator&lt;/code&gt; prop for painting extra SVG on top, which covers tapping a dot. What it doesn't hand you is the touch stream or the x-to-index mapping, so a continuous scrub with nearest-point hit-testing isn't something you can build on top of it. The library also sized itself from &lt;code&gt;Dimensions.get('window')&lt;/code&gt; instead of measuring its own container, so it never quite fit its card.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I was wrong about the library
&lt;/h2&gt;

&lt;p&gt;I removed chart-kit believing it was unmaintained. That belief was two months stale, and I should say so plainly.&lt;/p&gt;

&lt;p&gt;Here's the real release history from npm. Version 6.12.0 shipped in February 2022 and then nothing moved for four years, which is where my impression of the project froze. It woke up in 2026: 6.12.1 in April, 6.12.3 in May, a &lt;code&gt;next&lt;/code&gt; train through May and June, and &lt;strong&gt;v7.0.0 on 27 June 2026&lt;/strong&gt;. It's on 7.0.2 now with 133k weekly downloads.&lt;/p&gt;

&lt;p&gt;It gets worse for me. My lockfile had carried the app to 6.12.3, a May 2026 release, so I wasn't even running the four-year-old code I thought I was. What I was running was the pre-v7 architecture. The 6.12.x releases are maintenance work on the original &lt;code&gt;LineChart&lt;/code&gt;, and the rewrite lives behind v7's &lt;code&gt;/v2&lt;/code&gt; subpath, which 6.12.3 doesn't ship at all.&lt;/p&gt;

&lt;p&gt;v7 is a substantial rewrite behind a &lt;code&gt;react-native-chart-kit/v2&lt;/code&gt; subpath. Its release notes list "multi-series data, null gaps, smart labels, tooltips, crosshair, scrollable viewports, pan/zoom controls, range selector, markers, reference overlays, thresholds, decimation, and accessibility helpers", plus renderer-agnostic core packages for scales, layout, geometry and interaction. Read that against my list above. Null gaps and a crosshair address defects three and four directly. If I'd checked npm instead of my memory of npm, "upgrade" was a real option I never priced.&lt;/p&gt;

&lt;p&gt;I'd still make the same call, and the reasons that survive are better than the one I used:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The colour ramp is shared app state, not chart config.&lt;/strong&gt; The line is painted from the same mood ramp as the timeline dots and the heatmap cells. A theming API gives me a second palette that drifts from the first one on the next redesign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half the geometry already existed.&lt;/strong&gt; The Home tab's week chart left chart-kit three months earlier, and its pure &lt;code&gt;chartGeometry.ts&lt;/code&gt; was sitting right there. The marginal cost of the second chart was 750 lines, not 1,355.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A library that owns pan and zoom is a third competitor for the same finger.&lt;/strong&gt; More on that below. It's the reason I'd have had to fight v7's interaction layer rather than use it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's a narrower claim than "the library is dead", and it's the one I can defend.&lt;/p&gt;

&lt;p&gt;The other obvious move was a different library. &lt;code&gt;victory-native&lt;/code&gt; does 508k weekly downloads and &lt;code&gt;react-native-gifted-charts&lt;/code&gt; does 248k, and both handle gestures properly. Same three reasons apply to both, plus one more: swapping libraries is the same migration cost as writing it, without the part where I get to keep the geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split the geometry out before you draw anything
&lt;/h2&gt;

&lt;p&gt;No coordinate math lives in the component. Every domain, gridline, gradient stop and hit-test is a pure function in a &lt;code&gt;transforms/&lt;/code&gt; folder with zero React or React Native imports. The renderer is thin enough to be boring.&lt;/p&gt;

&lt;p&gt;That's not architecture for its own sake. A chart's bugs are almost all arithmetic, and arithmetic is the part you can test without a screen. Here's the vertical domain resolver, which answers "what range does this axis cover":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolveDomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)[],&lt;/span&gt;
    &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DomainMode&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ValueDomain&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fixed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;MOOD_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// always 0..10&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;MOOD_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// empty DB is a real code path&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIT_PAD_MIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FIT_PAD_RATIO&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;MOOD_MIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MOOD_MAX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;MOOD_MIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MOOD_MAX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Widen to a minimum span. A two-point series spanning 0.2 of a mood&lt;/span&gt;
    &lt;span class="c1"&gt;// would otherwise render as a dramatic mountain range.&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;FIT_MIN_SPAN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MOOD_MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MOOD_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two modes, because they answer different questions. &lt;code&gt;fixed&lt;/code&gt; is always 0 to 10, so this week and last week stay comparable at a glance. &lt;code&gt;fit&lt;/code&gt; zooms to the data's own range, which is what you want when every day was a 6 or a 7. &lt;code&gt;FIT_MIN_SPAN&lt;/code&gt; is 3, because a fitted domain of 6.1 to 6.3 turns statistical noise into a mountain range.&lt;/p&gt;

&lt;p&gt;The bounds snap to integers, so axis labels are whole moods and never "6.37".&lt;/p&gt;

&lt;h2&gt;
  
  
  Colour that carries information
&lt;/h2&gt;

&lt;p&gt;The line is painted with a vertical gradient built from the app's single mood ramp, so height reads as colour and not just position. High is green, low is amber and red. There's deliberately no second palette.&lt;/p&gt;

&lt;p&gt;The interesting part is a constant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * The canonical mood ramp bottoms out at 0.2 alpha. A fill can live there;
 * a 3px stroke cannot, and that faintness IS the "not bright enough"
 * complaint. The ramp's shape is preserved, its floor is raised.
 *
 * 0.85 (was 0.55): at 0.55 the stroke visibly DIMMED as it descended, so a
 * bad week looked like a rendering fault rather than a low mood.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LINE_MIN_OPACITY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped 0.55 first and it was wrong. A ramp tuned for filled shapes doesn't transfer to a 3px stroke, because a stroke has almost no area to carry the alpha. The fix keeps the ramp's shape and rescales it into a legible opacity window, instead of inventing a second ramp that would drift from the rest of the app.&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%2Fg4f01cwrm9umr61jpb0g.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%2Fg4f01cwrm9umr61jpb0g.png" alt="The chart expanded full-screen with the Fit domain switch on, zoomed to a 3 to 9 range" width="700" height="1400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Hold to scrub, tap to expand
&lt;/h2&gt;

&lt;p&gt;A long-press-then-drag &lt;code&gt;Pan&lt;/code&gt; from &lt;code&gt;react-native-gesture-handler&lt;/code&gt; gives you the whole scrub interaction in six lines, and a ref keeps it from re-rendering the chart on every pointer sample.&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;scrub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Gesture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activateAfterLongPress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SCRUB_ACTIVATE_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 220&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runOnJS&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&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;handleScrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onUpdate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&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;handleScrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onFinalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endScrub&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Race, not Simultaneous: a quick tap expands, a hold scrubs, never both.&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;onPress&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;Gesture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Race&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runOnJS(true)&lt;/code&gt; is deliberate, not laziness. The readout is React state and the haptic is a JS call, so there's nothing here worth a worklet.&lt;/p&gt;

&lt;p&gt;The ref is what stops a re-render storm. Gesture callbacks write the current index to &lt;code&gt;scrubIndexRef&lt;/code&gt; and only call &lt;code&gt;setState&lt;/code&gt; when the index actually &lt;strong&gt;changes&lt;/strong&gt;. On a slow drag across a month that's about 30 updates instead of thousands.&lt;/p&gt;

&lt;p&gt;Hit-testing is a separate pure function, and its comments are mostly about edges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nearestIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bestDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Strictly-less keeps the EARLIER point on an exact tie, so a scrub&lt;/span&gt;
        &lt;span class="c1"&gt;// across a midpoint switches once, at the midpoint, in both directions.&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bestDist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bestDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;xs&lt;/code&gt; holds only the &lt;strong&gt;real&lt;/strong&gt; data points, so a hold can never report a mood for a day that was never logged. Dragging past either end clamps to that end rather than dropping the cursor, because a cursor that vanishes mid-drag reads as a bug.&lt;/p&gt;

&lt;p&gt;One product decision is buried in there. The tooltip shows the day's average &lt;strong&gt;and&lt;/strong&gt; the last entry the user actually wrote that day, with its time and the first line of its note. An average isn't what anyone remembers about a Tuesday. The entry is.&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%2Fjdcwu6r9pw7rxpbbpwhe.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%2Fjdcwu6r9pw7rxpbbpwhe.png" alt="Holding the chart: a cursor line snapped to 25 Aug with a tooltip showing the average and that day's entry" width="700" height="1400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Pan gestures, one finger
&lt;/h2&gt;

&lt;p&gt;Two &lt;code&gt;Pan&lt;/code&gt; handlers and a vertical &lt;code&gt;ScrollView&lt;/code&gt; can share one finger without either gesture knowing the other exists. Three numbers and one RNGH rule do all the work.&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%2Frphwhofsl8ju39dypjmd.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%2Frphwhofsl8ju39dypjmd.png" alt="How RNGH arbitrates between the page-swipe pan, the hold-to-scrub pan and the ScrollView" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I added a second gesture to this screen after the chart: swipe sideways to step through periods. So now the scrub pan, the page pan and the scroller all want the same touch.&lt;/p&gt;

&lt;p&gt;The rule: when a non-simultaneous handler &lt;strong&gt;activates&lt;/strong&gt;, every other handler still in &lt;code&gt;BEGAN&lt;/code&gt; gets cancelled. The only question is which one activates first.&lt;/p&gt;

&lt;p&gt;The numbers: the scrub needs a 220 ms hold, the page pan needs 24 px of horizontal travel (&lt;code&gt;activeOffsetX&lt;/code&gt;), and 12 px of vertical travel fails it outright (&lt;code&gt;failOffsetY&lt;/code&gt;). Hold still and the scrub arrives long before your finger drifts 24 px. Flick sideways and the page pan wins in well under 220 ms. Drag downward and the pan fails, so the ScrollView keeps the touch and scrolls as it always did.&lt;/p&gt;

&lt;p&gt;The page pan is attached to a plain &lt;code&gt;View&lt;/code&gt; that's an &lt;strong&gt;ancestor&lt;/strong&gt; of the ScrollView, not to the ScrollView itself. Two handlers on one native view tag makes arbitration depend on registration order. On an ancestor it's unambiguous. The ScrollView is RNGH's rather than React Native's, so vertical scrolling joins the same arbitration instead of running its own.&lt;/p&gt;

&lt;p&gt;These thresholds are load-bearing in a way that's invisible in review. The comment above &lt;code&gt;ACTIVE_OFFSET_X&lt;/code&gt; says it out loud: don't lower it, and don't mark either gesture simultaneous with anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fabric trap that sent me back to the old Animated API
&lt;/h2&gt;

&lt;p&gt;The page transition uses React Native's built-in &lt;code&gt;Animated&lt;/code&gt; with &lt;code&gt;useNativeDriver: true&lt;/code&gt;, not Reanimated. That looks like a regression. It isn't.&lt;/p&gt;

&lt;p&gt;A live Reanimated &lt;code&gt;useAnimatedStyle&lt;/code&gt; on a &lt;code&gt;flex: 1&lt;/code&gt; container blanks this screen. The Statistics tab has about eight charts, each resolving its own async query and re-laying-out over roughly three seconds after mount. On one of those re-layouts, Reanimated applies its animated props against a stale measured frame and shoves the whole subtree about 1,600 px off-screen. The tab goes blank with no JS re-render at all, so nothing in the React tree looks wrong.&lt;/p&gt;

&lt;p&gt;I root-caused it on device after shipping it. The property being animated is irrelevant: an opacity-only animated style reproduces it. Only removing the animated style from the &lt;code&gt;flex: 1&lt;/code&gt; view fixes it. Lighter screens share the same code path and never reproduce, because their content doesn't repeatedly re-lay-out after mount.&lt;/p&gt;

&lt;p&gt;Native-driven &lt;code&gt;Animated&lt;/code&gt; doesn't have the bug. The transform is applied by the platform animation module to the view's own node, and is never recomputed from a JS-side measured layout. So the swipe uses it, with a long comment explaining why nobody should modernise it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a chart without taking a screenshot
&lt;/h2&gt;

&lt;p&gt;Pure geometry means the chart's tests are ordinary unit tests asserting invariants, not pixel snapshots. There are 131 of them across the chart transforms and renderers. A sample of the names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;gradient stop opacity decreases monotonically from top to bottom&lt;/li&gt;
&lt;li&gt;a fitted domain contains every data point it was fitted to&lt;/li&gt;
&lt;li&gt;an exact midpoint resolves to the earlier point, in both directions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nearestIndex&lt;/code&gt; is monotonic: sweeping right never moves the index left&lt;/li&gt;
&lt;li&gt;the tooltip never leaves the container, for any anchor&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;buildGridLines&lt;/code&gt; returns nothing for a degenerate domain instead of looping forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one I'd recommend to anyone doing a migration like this isn't a geometry test. It's a source-level guard:&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;IMPORTS_CHART_KIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;from&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;react-native-chart-kit&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|require&lt;/span&gt;&lt;span class="se"&gt;\(\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;react-native-chart-kit&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\))&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scans a non-trivial number of files (guards against a vacuous pass)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no app source imports it&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&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;IMPORTS_CHART_KIT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the regex really does catch an import (proves the check has teeth)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IMPORTS_CHART_KIT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`import { LineChart } from '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CHART_KIT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;';`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="c1"&gt;// A comment naming it is fine. This file and several others do.&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IMPORTS_CHART_KIT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`// replaces &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CHART_KIT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'s LineChart`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dependency is gone from &lt;code&gt;package.json&lt;/code&gt;, so a stray import fails at bundle time anyway. But that failure is a confusing "module not found" in Metro. This test states the actual rule, so the next new chart in this repo hears about it in review.&lt;/p&gt;

&lt;p&gt;Note the two supporting tests. One proves the file walker found something, so the check can't pass vacuously. One proves the regex has teeth. A guard test that silently stops guarding is worse than no guard, because you stop checking by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Check the registry, not your memory of the registry. I called a library unmaintained in a code comment, a changelog and a test docstring. It had shipped a major version two months before I wrote any of them.&lt;/p&gt;

&lt;p&gt;The second thing: I spent a while theming around the library before replacing it. Colour props, custom dot renderers, wrappers to fight the sizing. All of it was thrown away.&lt;/p&gt;

&lt;p&gt;The heuristic I'd use now has two parts. If two consecutive feature requests need the library to change its geometry or its gesture handling, stop theming and measure how much of the library you actually use. And before you write the word "unmaintained" anywhere, open npm.&lt;/p&gt;

&lt;p&gt;Open question, and I'd genuinely like other opinions. I kept the scrub on the JS thread because the readout is React state. If you've moved this kind of scrub fully into a worklet with a shared value driving the SVG cursor, was the complexity worth it on a mid-range Android device? Or is 60 Hz on the JS thread plenty for a gesture this coarse?&lt;/p&gt;

&lt;p&gt;The app is open source if you want to read the whole thing: &lt;a href="https://github.com/Antimatter543/mood-tracker" rel="noopener noreferrer"&gt;github.com/Antimatter543/mood-tracker&lt;/a&gt;. It's on &lt;a href="https://play.google.com/store/apps/details?id=com.raeduslabs.soulsyncapp" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Supabase vs Firebase: Which Should You Use in 2026</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:09:23 +0000</pubDate>
      <link>https://dev.to/astraedus/supabase-vs-firebase-which-should-you-use-in-2026-1jc5</link>
      <guid>https://dev.to/astraedus/supabase-vs-firebase-which-should-you-use-in-2026-1jc5</guid>
      <description>&lt;p&gt;Pick Supabase if your data is relational or you're adding AI features. Pick Firebase if you're building an offline-first mobile app that lives inside Google's ecosystem. That's the short answer, and for most projects it's the whole answer.&lt;/p&gt;

&lt;p&gt;The long answer is the rest of this post. The two backends made opposite bets about how your data should be shaped. That one choice leaks into your auth, your bill, and how hard it is to leave later.&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%2Fw06aoizm5iszmudbyt8f.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%2Fw06aoizm5iszmudbyt8f.png" alt="Supabase vs Firebase compared across data model, auth, realtime, AI, pricing, and lock-in" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've shipped four consumer apps to production on Supabase over the last year. I picked it on purpose, and I've also hit its sharp edges, so this is a comparison written from scars, not from a features page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model is the actual decision
&lt;/h2&gt;

&lt;p&gt;Everything else follows from one choice: relational or document. Supabase is managed Postgres, so your data is tables with real foreign keys, joins, and transactions. Firebase's Firestore is a NoSQL document store, so queries are shallow and cannot traverse relationships without extra reads or denormalized copies.&lt;/p&gt;

&lt;p&gt;Here is what that difference feels like in code. In Supabase you ask for a user and their posts in one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;author_id&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Firestore you fetch the user, then loop and fetch each related document, or you duplicate the user's name onto every post so you never have to join. Both work. One of them turns into a maintenance tax as your data grows.&lt;/p&gt;

&lt;p&gt;Firebase noticed this pain and shipped Data Connect (now Firebase SQL Connect), a managed Cloud SQL Postgres service with a GraphQL layer on top. It closes the "Firebase cannot do joins" gap. The catch: you're consuming a Google-managed endpoint through Firebase APIs, not owning a Postgres database the way you do on Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth and access control: RLS versus Security Rules
&lt;/h2&gt;

&lt;p&gt;Both platforms give you email, Google, GitHub, Apple, and phone sign-in out of the box. The real difference is where you write your access rules.&lt;/p&gt;

&lt;p&gt;Supabase puts them in the database with Postgres Row Level Security. A policy is SQL, and it runs no matter which client hits the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users read own rows"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firebase puts them in a separate rules file that guards the Firestore API:&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;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;documents&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&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;Neither is wrong. RLS keeps the rule next to the data, which I like because there is one source of truth. Firebase's rules are easier to read on day one but live away from your schema. Both platforms also support anonymous auth for "try before you sign up" flows. On Supabase, anonymous users even carry an &lt;code&gt;is_anonymous&lt;/code&gt; claim you can gate an RLS policy on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing: flat-ish versus pay-per-read
&lt;/h2&gt;

&lt;p&gt;This is where teams get surprised. Supabase is mostly a flat subscription. Firebase is metered by usage, and metered bills are the ones that spike.&lt;/p&gt;

&lt;p&gt;Supabase gives you two free projects (500 MB database, 50,000 monthly active users). Then the Pro plan is 25 dollars a month, with a 10 dollar compute credit that covers a small instance. Most early apps sit at exactly 25 dollars until they scale.&lt;/p&gt;

&lt;p&gt;Firebase's Spark plan is free with daily caps (50,000 Firestore reads, 20,000 writes). Above that you move to Blaze, which bills per operation: roughly 0.03 dollars per 100,000 reads and 0.09 dollars per 100,000 writes, with prices varying by region. Reads are the cheap part. That still sounds tiny until a chatty client re-reads a collection on every screen and your read count runs away from you.&lt;/p&gt;

&lt;p&gt;The mental model: Supabase costs are predictable and driven by compute and egress. Firebase costs are driven by how your app reads and writes, which is easy to underestimate before launch.&lt;/p&gt;

&lt;p&gt;One Supabase gotcha you must know: free projects pause after about a week of inactivity, and a paused project's subdomain stops resolving. It bit me once on a low-traffic app. Keep a free project warm with a scheduled ping, or put anything real on Pro.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI and vector search
&lt;/h2&gt;

&lt;p&gt;If you're building anything with embeddings, Supabase has the cleaner story. Its pgvector support means your embeddings, your application data, and your access policies all live in the same Postgres database, and you query them with plain SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0.1,0.2,0.3]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is RAG without a second datastore to sync. Firebase answers with AI Logic for Gemini integration and vector search through extensions, which works but keeps the pieces more separate. For AI-heavy apps, "same database" is a real advantage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock-in: how hard is it to leave
&lt;/h2&gt;

&lt;p&gt;Supabase is open source and runs on standard Postgres, so leaving is recoverable in weeks. You can even self-host the whole stack. Firebase is Google-managed with proprietary APIs, so leaving is structural and closer to months of work. If optionality matters to you, weight this heavily.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one?
&lt;/h2&gt;

&lt;p&gt;Choose Supabase when your data is relational, you want full SQL, you're adding AI or vector features, or you want the option to self-host. Choose Firebase when you're building an offline-first mobile app, or you want realtime sync and client caching with almost no setup. It also fits if you already live in Google's stack with Analytics, Crashlytics, and FCM.&lt;/p&gt;

&lt;p&gt;For my apps the deciding factors were relational data and pgvector, so Supabase won. If I were shipping a purely offline-first app tomorrow, I'd give Firebase an honest look for its client-side sync alone. The best backend is the one whose default shape matches your app's default shape.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>The TypeScript Gotcha That Silently Breaks Production (And How to Fix It)</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:09:25 +0000</pubDate>
      <link>https://dev.to/astraedus/the-typescript-gotcha-that-silently-breaks-production-and-how-to-fix-it-1f7k</link>
      <guid>https://dev.to/astraedus/the-typescript-gotcha-that-silently-breaks-production-and-how-to-fix-it-1f7k</guid>
      <description>&lt;p&gt;TypeScript's most expensive gotcha: it checks types at compile time, but your data shows up at runtime. Those two moments never meet. The compiler validates the shape you &lt;em&gt;declared&lt;/em&gt;, not the bytes the network &lt;em&gt;delivered&lt;/em&gt;, so code that typechecks clean and passes CI can still throw &lt;code&gt;Cannot read properties of undefined&lt;/code&gt; the first time a real user hits it.&lt;/p&gt;

&lt;p&gt;I've shipped this bug. A backend quietly dropped a field, the frontend still compiled and rendered fine in CI, and it blew up in production the moment a real account without that field loaded. You've probably lived some version of it. Here's exactly why it happens and how to stop it.&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%2Fec50ypp0d09tb2wbpuww.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%2Fec50ypp0d09tb2wbpuww.png" alt="TypeScript checks types at compile time; data arrives at runtime. Untyped values from res.json(), JSON.parse, process.env and array access flow into your program. An  raw `as` endraw  assertion bypasses checking and crashes in production; a validation gate (type guard or schema) lets you trust the type inside." width="799" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-minute version
&lt;/h2&gt;

&lt;p&gt;Types are erased before your code runs. &lt;code&gt;tsc&lt;/code&gt; reads your annotations, checks them against each other, then deletes every last one and emits plain JavaScript. At runtime there is no &lt;code&gt;User&lt;/code&gt; type, no &lt;code&gt;string&lt;/code&gt;, no &lt;code&gt;number&lt;/code&gt; guarantee. There is only whatever your API, your &lt;code&gt;JSON.parse&lt;/code&gt;, your environment variables, and your database actually handed you.&lt;/p&gt;

&lt;p&gt;That means a type is a promise &lt;em&gt;you&lt;/em&gt; make to the compiler. It is not a promise the outside world keeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch it crash
&lt;/h2&gt;

&lt;p&gt;Here is a &lt;code&gt;User&lt;/code&gt; and a value that "is" a &lt;code&gt;User&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// This actually came from an API that returned { id: 1, name: "Ada" }&lt;/span&gt;
&lt;span class="c1"&gt;// (no email field, a backend change nobody told the frontend about)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":1,"name":"Ada"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// compiler: "looks good to me"&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// 💥&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;tsc --strict --noEmit&lt;/code&gt; on that. It passes. Zero errors. Then run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Cannot read properties of undefined (reading 'toLowerCase')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;as User&lt;/code&gt; is the trap. A type assertion doesn't check anything. It's you telling the compiler "trust me, stop looking," and the compiler happily obeys. The moment the real data disagrees with your assertion, you get a runtime crash with a stack trace pointing at the &lt;em&gt;symptom&lt;/em&gt;, three functions away from the actual lie.&lt;/p&gt;

&lt;p&gt;Every one of these is the same gotcha wearing a different hat:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;const data = await res.json()&lt;/code&gt;: &lt;code&gt;res.json()&lt;/code&gt; returns &lt;code&gt;Promise&amp;lt;any&amp;gt;&lt;/code&gt;, and &lt;code&gt;any&lt;/code&gt; is a hole in the type system that swallows every check downstream.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JSON.parse(...)&lt;/code&gt;: also &lt;code&gt;any&lt;/code&gt;. Same hole.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;process.env.PORT&lt;/code&gt;: typed &lt;code&gt;string | undefined&lt;/code&gt;, but people &lt;code&gt;Number(...)&lt;/code&gt; it or slap a &lt;code&gt;!&lt;/code&gt; on it and forget it can be missing (hello, &lt;code&gt;NaN&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;arr[i]&lt;/code&gt;: this one is worse, because it lies &lt;em&gt;by default&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one that lies by default
&lt;/h2&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;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ada&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;third&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;           &lt;span class="c1"&gt;// TypeScript says: string&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;third&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// undefined at runtime → 💥&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;names[2]&lt;/code&gt; is typed &lt;code&gt;string&lt;/code&gt;. At runtime it is &lt;code&gt;undefined&lt;/code&gt;. TypeScript, by default, assumes every array index is populated, which is optimistic to the point of being wrong. Turn on one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tsconfig.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"noUncheckedIndexedAccess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same line becomes a compile error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error TS18048: 'third' is possibly 'undefined'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug moved from your production logs to your editor. That is the whole game: pull the failure earlier in time, from a paged 2am incident to a red squiggle you fix before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: validate at the boundary
&lt;/h2&gt;

&lt;p&gt;The mental model that fixes this permanently: &lt;strong&gt;trust types inside your program, never at its edges.&lt;/strong&gt; Every place data enters from the outside world (network, disk, &lt;code&gt;JSON.parse&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, form input) is a boundary, and a boundary needs a runtime check, not a compile-time assertion.&lt;/p&gt;

&lt;p&gt;You don't need a library for this. A type guard is a plain function that returns a special boolean:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;User&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":1,"name":"Ada"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no email&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;API returned a shape we don't trust&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Past this line, `raw` is a real User: checked, not asserted.&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the failure happens &lt;em&gt;at the boundary&lt;/em&gt;, with a message that names the actual problem ("API returned a shape we don't trust"), instead of a &lt;code&gt;TypeError&lt;/code&gt; deep inside a render function. The &lt;code&gt;v is User&lt;/code&gt; return type tells the compiler that inside the &lt;code&gt;if&lt;/code&gt;, the value is narrowed to &lt;code&gt;User&lt;/code&gt;. No &lt;code&gt;as&lt;/code&gt; needed after the guard, because you earned the type instead of asserting it.&lt;/p&gt;

&lt;p&gt;For anything bigger than a couple of fields, reach for a schema validator like &lt;a href="https://zod.dev" rel="noopener noreferrer"&gt;Zod&lt;/a&gt; or Valibot. They generate both the runtime check and the static type from one definition, so the two can't drift apart:&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;z&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;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&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;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// throws a precise, field-level error if the API lied&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One source of truth. The static type and the runtime guard are the same object.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;TypeScript is a compile-time tool doing a compile-time job extremely well. It was never going to check the network for you, because the network doesn't exist when it runs. The gotcha is not a TypeScript flaw. It is a mismatch between where you &lt;em&gt;think&lt;/em&gt; the checking happens and where it &lt;em&gt;actually&lt;/em&gt; happens.&lt;/p&gt;

&lt;p&gt;Three moves close the gap for good:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ban &lt;code&gt;as&lt;/code&gt; on external data. If it came from outside your program, assert nothing.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;noUncheckedIndexedAccess&lt;/code&gt; (and &lt;code&gt;strict&lt;/code&gt;, if you somehow still have not).&lt;/li&gt;
&lt;li&gt;Validate every boundary with a type guard or a schema, so the type you trust inside is the shape you verified at the edge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do that, and the class of bug that typechecks clean and crashes in production stops existing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>Prompt Chains vs AI Agents: Which Should You Use in 2026</title>
      <dc:creator>Diven Rastdus</dc:creator>
      <pubDate>Fri, 28 Aug 2026 10:11:27 +0000</pubDate>
      <link>https://dev.to/astraedus/prompt-chains-vs-ai-agents-which-should-you-use-in-2026-9h2</link>
      <guid>https://dev.to/astraedus/prompt-chains-vs-ai-agents-which-should-you-use-in-2026-9h2</guid>
      <description>&lt;p&gt;Use a prompt chain when you can name the steps before you run them, even if there are several. Reach for an agent only when the model has to look at each result and decide its own next step from something it cannot predict. Most tasks people hand to an "agent" are the first kind. Swapping a chain for an agent there is how you turn a predictable two-second pipeline into a 30-second, many-times-the-cost, hard-to-debug loop.&lt;/p&gt;

&lt;p&gt;I run a production system that is almost entirely automated. It spawns subagents, calls models hundreds of times a day, and ships real work. The surprising part: most of it is not agents. It is plain code calling single LLM calls at the right moments. The agent loops are a small, deliberate minority, and every one of them earns its place. Here is how I decide.&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%2Ffhf7mjs8dzmrzxtu2zof.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%2Ffhf7mjs8dzmrzxtu2zof.png" alt="Decision tree: single LLM call vs prompt chain vs fixed workflow vs AI agent" width="800" height="614"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually separates a chain from an agent?
&lt;/h2&gt;

&lt;p&gt;A chain is a function you wrote; an agent is a loop the model drives. That's the whole distinction, and it's the one people skip.&lt;/p&gt;

&lt;p&gt;In a prompt chain, your code owns the control flow. You decide the order, the branches, and where each model call goes. An agent hands that control flow to the model. The LLM decides what to do next, does it, looks at the result, and decides again until it thinks it's done. That autonomy is powerful and expensive. You trade determinism for the ability to handle problems whose shape you do not know ahead of time. The simplest chain of all is a single call, so start there and climb only when you must.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Anthropic&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Single call: you own the control flow.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Classify this ticket as bug/billing/other:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That function is boring, and boring is the point. It runs in one round trip, costs one set of tokens, and returns the same shape every time. You can unit-test it, cache it, and reason about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does a single call win? (default here)
&lt;/h2&gt;

&lt;p&gt;A single call wins whenever the task is one transformation and the context fits in the prompt. Classification, extraction, summarization, rewriting, translation, structured-data generation, sentiment, routing: these are single calls, and dressing them up as agents only adds failure modes.&lt;/p&gt;

&lt;p&gt;Three reasons the single call is the default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; An agent that takes five internal steps sends roughly five times the tokens, and every step re-sends the growing history. A ten-step agent can cost twenty times a single call for the same answer. Prompt caching claws some of that back, since cache reads run about ten times cheaper, but you still pay for every fresh loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; Each loop iteration is a full round trip. One call is one round trip. Users feel the difference between 300ms and 15 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debuggability.&lt;/strong&gt; When a single call is wrong, you read one prompt and one response. When an agent is wrong, you replay a branching transcript and guess which of nine decisions went sideways.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can name the exact steps in advance, you don't need an agent. You need code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does an agent earn its complexity?
&lt;/h2&gt;

&lt;p&gt;An agent earns its keep when the number of steps is unknown, the path branches on results you cannot predict, and the model needs tools to act on the world. Think "investigate this failing test until you find the cause," not "summarize this text."&lt;/p&gt;

&lt;p&gt;The tell is uncertainty about the path. A coding agent does not know how many files it must read before it finds the bug. A research agent does not know which search will surface the answer. That's real agent territory, because a fixed script cannot encode a path that depends on what the model learns mid-task.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Agent: the MODEL owns the control flow. Note the while loop.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# a hard cap is not optional
&lt;/span&gt;        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;  &lt;span class="c1"&gt;# text is the last block when thinking is off
&lt;/span&gt;        &lt;span class="c1"&gt;# Claude can return several tool_use blocks at once (parallel tools),
&lt;/span&gt;        &lt;span class="c1"&gt;# so collect every result and append ONE user turn. Splitting them into
&lt;/span&gt;        &lt;span class="c1"&gt;# separate messages is the bug everyone ships first.
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_use&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_use_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hit step limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at what the loop buys you and what it costs. It buys adaptation: the model can read a file, decide it needs another, and keep going. It costs you a hard step cap, a growing context window, tool-error handling, and a transcript you have to trust. You take that trade only when the adaptation is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt chain most people mislabel as an agent
&lt;/h2&gt;

&lt;p&gt;Between "one call" and "full agent" sits the option that solves 80% of the hard cases: a prompt chain where your code orchestrates several calls. You keep the control flow. The model just fills in the smart parts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Fixed workflow: known steps, deterministic order, no autonomy.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# call 1
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;summarize_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# call 2a
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;summarize_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eng&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# call 2b
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a prompt chain, and it's not an agent. You know there are exactly two calls. You know the order. The branch is a plain &lt;code&gt;if&lt;/code&gt;, not a model decision. You get the intelligence of the model with the reliability of code, and you can test every path. When people say "we built an agent" and it works great in production, this is usually what they actually built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, in one pass
&lt;/h2&gt;

&lt;p&gt;Walk the tree in the diagram top to bottom and stop at the first match:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One well-defined transformation, context in hand? &lt;strong&gt;Single call.&lt;/strong&gt; Do not overthink it.&lt;/li&gt;
&lt;li&gt;Multiple steps, no tools, order known? &lt;strong&gt;Prompt chain.&lt;/strong&gt; Code the steps.&lt;/li&gt;
&lt;li&gt;Steps known, tools involved, path fixed? &lt;strong&gt;Fixed workflow.&lt;/strong&gt; Code owns the flow.&lt;/li&gt;
&lt;li&gt;Path genuinely unknown until the model runs? &lt;strong&gt;Agent.&lt;/strong&gt; Cap the loop and watch it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bias should point up the list, not down. Every rung down adds cost, latency, and surface area for bugs. Start at the top and only descend when the task forces you to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Agents are not an upgrade to a prompt chain. They are a different tool with a different price tag. In 2026 the fastest way to a slow, expensive, flaky feature is to reach for an agent when a chain would do. Ask one question before you build: do I know the steps in advance? If yes, you want a chain, not autonomy. If no, and only if no, you want an agent, and you want a step limit on it.&lt;/p&gt;

&lt;p&gt;The best "agentic" systems I have shipped are mostly not agents. They are boring chains with a few smart calls in the right places, and one or two real loops where the path is truly unknown. Boring scales. Autonomy is the exception you spend deliberately.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these from real work at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt;, where I build apps and tools. Building something, or stuck on something like this? Reach me at &lt;a href="https://astraedus.dev" rel="noopener noreferrer"&gt;astraedus.dev&lt;/a&gt; or &lt;a href="mailto:theagentthatcould@gmail.com"&gt;theagentthatcould@gmail.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Get the next one in your inbox → &lt;a href="https://astraedus.dev/#subscribe" rel="noopener noreferrer"&gt;subscribe at astraedus.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
